Skip to content

Commit ad067cf

Browse files
authored
better limit tests (#6)
* better limit tests * Limits * knip
1 parent 64cb06f commit ad067cf

25 files changed

+1412
-118
lines changed

README.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Supports optional network access via `curl` with secure-by-default URL filtering
99
## Security model
1010

1111
- The shell only has access to the provided file system.
12-
- Execution is protected against infinite loops or recursion through.
12+
- Execution is protected against infinite loops or recursion through. However, BashEnv is not fully robust against DOS from input. If you need to be robust against this, use process isolation at the OS level.
1313
- Binaries or even WASM are inherently unsupported (Use [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) or a similar product if a full VM is needed).
1414
- There is no network access by default.
1515
- Network access can be enabled, but requests are checked against URL prefix allow-lists and HTTP-method allow-lists. See [network access](#network-access) for details
@@ -44,8 +44,7 @@ const env = new BashEnv({
4444
files: { "/data/file.txt": "content" }, // Initial files
4545
env: { MY_VAR: "value" }, // Initial environment
4646
cwd: "/app", // Starting directory (default: /home/user)
47-
maxCallDepth: 50, // Max recursion (default: 100)
48-
maxLoopIterations: 5000, // Max iterations (default: 10000)
47+
executionLimits: { maxCallDepth: 50 }, // See "Execution Protection"
4948
});
5049

5150
// Per-exec overrides
@@ -150,6 +149,7 @@ bash-env -c 'echo hello' --json
150149
The CLI uses OverlayFS - reads come from the real filesystem, but all writes stay in memory and are discarded after execution. The project root is mounted at `/home/user/project`.
151150

152151
Options:
152+
153153
- `-c <script>` - Execute script from argument
154154
- `--root <path>` - Root directory (default: current directory)
155155
- `--cwd <path>` - Working directory in sandbox
@@ -274,7 +274,21 @@ curl -X POST -H "Content-Type: application/json" \
274274

275275
## Execution Protection
276276

277-
BashEnv protects against infinite loops and deep recursion with configurable limits (`maxCallDepth`, `maxLoopIterations`). Error messages include hints on how to increase limits.
277+
BashEnv protects against infinite loops and deep recursion with configurable limits:
278+
279+
```typescript
280+
const env = new BashEnv({
281+
executionLimits: {
282+
maxCallDepth: 100, // Max function recursion depth
283+
maxCommandCount: 10000, // Max total commands executed
284+
maxLoopIterations: 10000, // Max iterations per loop
285+
maxAwkIterations: 10000, // Max iterations in awk programs
286+
maxSedIterations: 10000, // Max iterations in sed scripts
287+
},
288+
});
289+
```
290+
291+
All limits have sensible defaults. Error messages include hints on which limit to increase. Feel free to increase if your scripts intentionally go beyond them.
278292

279293
## Development
280294

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
},
3838
"scripts": {
3939
"build": "tsc",
40+
"validate": "pnpm build && pnpm typecheck && pnpm lint && pnpm knip && pnpm test:run",
4041
"typecheck": "tsc --noEmit",
4142
"lint": "biome check .",
4243
"lint:fix": "biome check --write .",

src/BashEnv.ts

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@ import {
1616
} from "./commands/registry.js";
1717
import { type IFileSystem, VirtualFs } from "./fs.js";
1818
import type { InitialFiles } from "./fs-interface.js";
19-
import { ArithmeticError, ExitError } from "./interpreter/errors.js";
19+
import {
20+
ArithmeticError,
21+
ExecutionLimitError,
22+
ExitError,
23+
} from "./interpreter/errors.js";
2024
import {
2125
Interpreter,
2226
type InterpreterOptions,
2327
type InterpreterState,
2428
} from "./interpreter/index.js";
29+
import { type ExecutionLimits, resolveLimits } from "./limits.js";
2530
import {
2631
createSecureFetch,
2732
type NetworkConfig,
@@ -30,18 +35,29 @@ import {
3035
import { type ParseException, parse } from "./parser/parser.js";
3136
import type { BashExecResult, Command, CommandRegistry } from "./types.js";
3237

33-
// Default protection limits
34-
const DEFAULT_MAX_CALL_DEPTH = 100;
35-
const DEFAULT_MAX_COMMAND_COUNT = 100000;
36-
const DEFAULT_MAX_LOOP_ITERATIONS = 10000;
38+
export type { ExecutionLimits } from "./limits.js";
3739

3840
export interface BashEnvOptions {
3941
files?: InitialFiles;
4042
env?: Record<string, string>;
4143
cwd?: string;
4244
fs?: IFileSystem;
45+
/**
46+
* Execution limits to prevent runaway compute.
47+
* See ExecutionLimits interface for available options.
48+
*/
49+
executionLimits?: ExecutionLimits;
50+
/**
51+
* @deprecated Use executionLimits.maxCallDepth instead
52+
*/
4353
maxCallDepth?: number;
54+
/**
55+
* @deprecated Use executionLimits.maxCommandCount instead
56+
*/
4457
maxCommandCount?: number;
58+
/**
59+
* @deprecated Use executionLimits.maxLoopIterations instead
60+
*/
4561
maxLoopIterations?: number;
4662
/**
4763
* Network configuration for commands like curl.
@@ -85,9 +101,7 @@ export class BashEnv {
85101
readonly fs: IFileSystem;
86102
private commands: CommandRegistry = new Map();
87103
private useDefaultLayout: boolean = false;
88-
private maxCallDepth: number;
89-
private maxCommandCount: number;
90-
private maxLoopIterations: number;
104+
private limits: Required<ExecutionLimits>;
91105
private secureFetch?: SecureFetch;
92106
private sleepFn?: (ms: number) => Promise<void>;
93107

@@ -112,10 +126,20 @@ export class BashEnv {
112126
...options.env,
113127
};
114128

115-
this.maxCallDepth = options.maxCallDepth ?? DEFAULT_MAX_CALL_DEPTH;
116-
this.maxCommandCount = options.maxCommandCount ?? DEFAULT_MAX_COMMAND_COUNT;
117-
this.maxLoopIterations =
118-
options.maxLoopIterations ?? DEFAULT_MAX_LOOP_ITERATIONS;
129+
// Resolve limits: new executionLimits takes precedence, then deprecated individual options
130+
this.limits = resolveLimits({
131+
...options.executionLimits,
132+
// Support deprecated individual options (they override executionLimits if set)
133+
...(options.maxCallDepth !== undefined && {
134+
maxCallDepth: options.maxCallDepth,
135+
}),
136+
...(options.maxCommandCount !== undefined && {
137+
maxCommandCount: options.maxCommandCount,
138+
}),
139+
...(options.maxLoopIterations !== undefined && {
140+
maxLoopIterations: options.maxLoopIterations,
141+
}),
142+
});
119143

120144
// Create secure fetch if network is configured
121145
if (options.network) {
@@ -208,10 +232,10 @@ export class BashEnv {
208232
}
209233

210234
this.state.commandCount++;
211-
if (this.state.commandCount > this.maxCommandCount) {
235+
if (this.state.commandCount > this.limits.maxCommandCount) {
212236
return {
213237
stdout: "",
214-
stderr: `bash: maximum command count (${this.maxCommandCount}) exceeded (possible infinite loop). Increase with maxCommandCount option.\n`,
238+
stderr: `bash: maximum command count (${this.limits.maxCommandCount}) exceeded (possible infinite loop). Increase with executionLimits.maxCommandCount option.\n`,
215239
exitCode: 1,
216240
env: { ...this.state.env, ...options?.env },
217241
};
@@ -260,9 +284,7 @@ export class BashEnv {
260284
const interpreterOptions: InterpreterOptions = {
261285
fs: this.fs,
262286
commands: this.commands,
263-
maxCallDepth: this.maxCallDepth,
264-
maxCommandCount: this.maxCommandCount,
265-
maxLoopIterations: this.maxLoopIterations,
287+
limits: this.limits,
266288
exec: this.exec.bind(this),
267289
fetch: this.secureFetch,
268290
sleep: this.sleepFn,
@@ -290,6 +312,16 @@ export class BashEnv {
290312
env: { ...this.state.env, ...options?.env },
291313
};
292314
}
315+
// ExecutionLimitError is thrown when our conservative limits are exceeded
316+
// (command count, recursion depth, loop iterations)
317+
if (error instanceof ExecutionLimitError) {
318+
return {
319+
stdout: error.stdout,
320+
stderr: error.stderr,
321+
exitCode: ExecutionLimitError.EXIT_CODE,
322+
env: { ...this.state.env, ...options?.env },
323+
};
324+
}
293325
if ((error as ParseException).name === "ParseException") {
294326
return {
295327
stdout: "",
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { describe, expect, it } from "vitest";
2+
import { BashEnv } from "../../BashEnv.js";
3+
4+
/**
5+
* AWK Execution Limits Tests
6+
*
7+
* These tests verify that awk commands cannot cause runaway compute.
8+
* AWK programs should complete in bounded time regardless of input.
9+
*
10+
* IMPORTANT: All tests should complete quickly (<1s each).
11+
*/
12+
13+
describe("AWK Execution Limits", () => {
14+
describe("infinite loop protection", () => {
15+
it("should protect against while(1) infinite loop", async () => {
16+
const env = new BashEnv();
17+
const result = await env.exec(
18+
`echo "test" | awk 'BEGIN { while(1) print "x" }'`,
19+
);
20+
21+
// Should not hang - awk should have iteration limits
22+
expect(result.stderr.length).toBeGreaterThan(0);
23+
expect(result.exitCode).not.toBe(0);
24+
});
25+
26+
it("should protect against for loop with always-true condition", async () => {
27+
const env = new BashEnv();
28+
const result = await env.exec(
29+
`echo "test" | awk 'BEGIN { for(i=0; 1; i++) print "x" }'`,
30+
);
31+
32+
expect(result.stderr.length).toBeGreaterThan(0);
33+
expect(result.exitCode).not.toBe(0);
34+
});
35+
36+
// TODO: for(;;) requires special parsing - skip for now
37+
it.skip("should protect against for(;;) infinite loop", async () => {
38+
const env = new BashEnv();
39+
const result = await env.exec(
40+
`echo "test" | awk 'BEGIN { for(;;) print "x" }'`,
41+
);
42+
43+
expect(result.stderr.length).toBeGreaterThan(0);
44+
expect(result.exitCode).not.toBe(0);
45+
});
46+
47+
// TODO: do-while not implemented in our awk
48+
it.skip("should protect against do-while infinite loop", async () => {
49+
const env = new BashEnv();
50+
const result = await env.exec(
51+
`echo "test" | awk 'BEGIN { do { print "x" } while(1) }'`,
52+
);
53+
54+
expect(result.stderr.length).toBeGreaterThan(0);
55+
expect(result.exitCode).not.toBe(0);
56+
});
57+
});
58+
59+
// TODO: User-defined functions not implemented in our awk
60+
describe.skip("recursion protection", () => {
61+
it("should protect against recursive function calls", async () => {
62+
const env = new BashEnv();
63+
const result = await env.exec(
64+
`echo "test" | awk 'function f() { f() } BEGIN { f() }'`,
65+
);
66+
67+
expect(result.stderr.length).toBeGreaterThan(0);
68+
expect(result.exitCode).not.toBe(0);
69+
});
70+
71+
it("should protect against mutual recursion", async () => {
72+
const env = new BashEnv();
73+
const result = await env.exec(
74+
`echo "test" | awk 'function a() { b() } function b() { a() } BEGIN { a() }'`,
75+
);
76+
77+
expect(result.stderr.length).toBeGreaterThan(0);
78+
expect(result.exitCode).not.toBe(0);
79+
});
80+
});
81+
82+
describe("output size limits", () => {
83+
it("should limit output from print in loop", async () => {
84+
const env = new BashEnv();
85+
const result = await env.exec(
86+
`echo "test" | awk 'BEGIN { for(i=0; i<1000000; i++) print "x" }'`,
87+
);
88+
89+
// Should either error or limit output
90+
if (result.exitCode === 0) {
91+
expect(result.stdout.length).toBeLessThan(10_000_000);
92+
}
93+
});
94+
95+
it("should limit string concatenation growth", async () => {
96+
const env = new BashEnv();
97+
const result = await env.exec(
98+
`echo "test" | awk 'BEGIN { s="x"; for(i=0; i<30; i++) s=s s; print length(s) }'`,
99+
);
100+
101+
// Should either error or limit string size
102+
expect(result.exitCode).toBeDefined();
103+
});
104+
});
105+
106+
describe("array limits", () => {
107+
it("should handle large array creation", async () => {
108+
const env = new BashEnv();
109+
const result = await env.exec(
110+
`echo "test" | awk 'BEGIN { for(i=0; i<100000; i++) a[i]=i; print length(a) }'`,
111+
);
112+
113+
// Should complete without hanging
114+
expect(result.exitCode).toBeDefined();
115+
});
116+
});
117+
118+
// TODO: ReDoS protection requires a different regex engine or timeout
119+
// JavaScript's regex engine is vulnerable to pathological patterns
120+
describe.skip("regex limits", () => {
121+
it("should handle pathological regex patterns", async () => {
122+
const env = new BashEnv();
123+
// ReDoS-style pattern
124+
const result = await env.exec(
125+
`echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab" | awk '/^(a+)+$/'`,
126+
);
127+
128+
// Should complete quickly
129+
expect(result.exitCode).toBeDefined();
130+
});
131+
});
132+
133+
describe("getline limits", () => {
134+
it("should not hang on getline in loop", async () => {
135+
const env = new BashEnv();
136+
const result = await env.exec(
137+
`echo "test" | awk '{ while((getline line < "/dev/zero") > 0) print line }'`,
138+
);
139+
140+
// Should either error or be handled safely
141+
expect(result.exitCode).toBeDefined();
142+
});
143+
});
144+
});

src/commands/awk/awk.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export const awkCommand: Command = {
106106
vars,
107107
arrays: {},
108108
fieldSep,
109+
maxIterations: ctx.limits?.maxAwkIterations,
109110
};
110111

111112
let stdout = "";

0 commit comments

Comments
 (0)