@crewhaus/sandbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crewhaus/sandbox",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Containerised exec environment with docker/podman/noop backends; production safety floor",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "author": {
19
+ "name": "Max Meier",
20
+ "email": "max@studiomax.io",
21
+ "url": "https://studiomax.io"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/crewhaus/factory.git",
26
+ "directory": "packages/sandbox"
27
+ },
28
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/sandbox#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/crewhaus/factory/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "restricted"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ]
41
+ }
@@ -0,0 +1,296 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { SANDBOX_DEFAULT_ALLOWED_IMAGES, SandboxError, createSandbox } from "./index";
3
+
4
+ const ORIGINAL_ENV = { ...process.env };
5
+ function resetEnv(): void {
6
+ for (const key of Object.keys(process.env)) {
7
+ if (key.startsWith("CREWHAUS_SANDBOX")) delete process.env[key];
8
+ }
9
+ for (const [k, v] of Object.entries(ORIGINAL_ENV)) {
10
+ if (k.startsWith("CREWHAUS_SANDBOX") && v !== undefined) process.env[k] = v;
11
+ }
12
+ }
13
+
14
+ describe("createSandbox factory", () => {
15
+ beforeEach(() => {
16
+ resetEnv();
17
+ });
18
+ afterEach(() => {
19
+ resetEnv();
20
+ });
21
+
22
+ test("backend resolves from env", () => {
23
+ process.env["CREWHAUS_SANDBOX"] = "noop";
24
+ const s = createSandbox();
25
+ expect(s.backend).toBe("noop");
26
+ });
27
+
28
+ test("explicit option overrides env", () => {
29
+ process.env["CREWHAUS_SANDBOX"] = "docker";
30
+ const s = createSandbox({ backend: "noop" });
31
+ expect(s.backend).toBe("noop");
32
+ });
33
+
34
+ test("invalid env value throws at construction", () => {
35
+ process.env["CREWHAUS_SANDBOX"] = "vagrant";
36
+ expect(() => createSandbox()).toThrow(SandboxError);
37
+ });
38
+
39
+ test("default allowlist exposes the curated image list", () => {
40
+ expect(SANDBOX_DEFAULT_ALLOWED_IMAGES).toContain("python:3.13-slim");
41
+ expect(SANDBOX_DEFAULT_ALLOWED_IMAGES).toContain("node:22-alpine");
42
+ expect(SANDBOX_DEFAULT_ALLOWED_IMAGES).toContain("alpine:3.19");
43
+ });
44
+ });
45
+
46
+ describe("noop backend exec", () => {
47
+ beforeEach(() => {
48
+ process.env["CREWHAUS_SANDBOX"] = "noop";
49
+ });
50
+ afterEach(() => {
51
+ resetEnv();
52
+ });
53
+
54
+ test("runs argv and captures stdout", async () => {
55
+ const sandbox = createSandbox();
56
+ const result = await sandbox.exec({
57
+ image: "python:3.13-slim",
58
+ argv: ["printf", "hello"],
59
+ });
60
+ expect(result.exitCode).toBe(0);
61
+ expect(result.stdout).toBe("hello");
62
+ expect(result.timedOut).toBe(false);
63
+ expect(result.durationMs).toBeGreaterThan(0);
64
+ });
65
+
66
+ test("propagates non-zero exit code", async () => {
67
+ const sandbox = createSandbox();
68
+ const result = await sandbox.exec({
69
+ image: "alpine:3.19",
70
+ argv: ["sh", "-c", "echo nope >&2; exit 17"],
71
+ });
72
+ expect(result.exitCode).toBe(17);
73
+ expect(result.stderr).toContain("nope");
74
+ });
75
+
76
+ test("times out and marks timedOut=true", async () => {
77
+ const sandbox = createSandbox();
78
+ const result = await sandbox.exec({
79
+ image: "alpine:3.19",
80
+ argv: ["sleep", "5"],
81
+ timeoutMs: 100,
82
+ });
83
+ expect(result.timedOut).toBe(true);
84
+ // SIGKILL via Bun -> exitCode is non-zero (typically negative for signals)
85
+ expect(result.exitCode).not.toBe(0);
86
+ });
87
+
88
+ test("streams stdout chunks to onStdoutChunk", async () => {
89
+ const sandbox = createSandbox();
90
+ const chunks: string[] = [];
91
+ await sandbox.exec({
92
+ image: "python:3.13-slim",
93
+ argv: ["printf", "abcdefg"],
94
+ onStdoutChunk: (c) => chunks.push(c),
95
+ });
96
+ expect(chunks.join("")).toBe("abcdefg");
97
+ });
98
+ });
99
+
100
+ describe("image allowlist", () => {
101
+ beforeEach(() => {
102
+ process.env["CREWHAUS_SANDBOX"] = "noop";
103
+ });
104
+ afterEach(() => {
105
+ resetEnv();
106
+ });
107
+
108
+ test("default allowlist accepts curated images", async () => {
109
+ const sandbox = createSandbox();
110
+ const result = await sandbox.exec({ image: "alpine:3.19", argv: ["printf", "ok"] });
111
+ expect(result.stdout).toBe("ok");
112
+ });
113
+
114
+ test("rejects unknown image", async () => {
115
+ const sandbox = createSandbox();
116
+ await expect(sandbox.exec({ image: "evil:latest", argv: ["true"] })).rejects.toThrow(
117
+ /not on the allowlist/,
118
+ );
119
+ });
120
+
121
+ test("rejects image starting with dash (CLI flag injection)", async () => {
122
+ const sandbox = createSandbox();
123
+ await expect(sandbox.exec({ image: "--privileged", argv: ["true"] })).rejects.toThrow(
124
+ /CLI flag/,
125
+ );
126
+ });
127
+
128
+ test("rejects image with whitespace (newline injection)", async () => {
129
+ const sandbox = createSandbox();
130
+ await expect(
131
+ sandbox.exec({ image: "alpine:3.19\n--privileged", argv: ["true"] }),
132
+ ).rejects.toThrow(/whitespace|valid registry/);
133
+ });
134
+
135
+ test("rejects image with shell-meta tag", async () => {
136
+ const sandbox = createSandbox();
137
+ await expect(sandbox.exec({ image: "alpine:$(id)", argv: ["true"] })).rejects.toThrow(
138
+ /valid registry/,
139
+ );
140
+ });
141
+
142
+ test("env CREWHAUS_SANDBOX_ALLOWED_IMAGES extends allowlist", async () => {
143
+ process.env["CREWHAUS_SANDBOX_ALLOWED_IMAGES"] = "busybox:1.36";
144
+ const sandbox = createSandbox();
145
+ // No throw: image is now allowed (will run via Bun.spawn with non-existent
146
+ // binary args but the allowlist check passes first).
147
+ await sandbox.exec({ image: "busybox:1.36", argv: ["printf", "x"] });
148
+ });
149
+
150
+ test("explicit allowedImages overrides default", async () => {
151
+ const sandbox = createSandbox({ allowedImages: ["my:image"] });
152
+ await expect(sandbox.exec({ image: "alpine:3.19", argv: ["true"] })).rejects.toThrow(
153
+ /not on the allowlist/,
154
+ );
155
+ });
156
+ });
157
+
158
+ describe("mount whitelist", () => {
159
+ beforeEach(() => {
160
+ process.env["CREWHAUS_SANDBOX"] = "noop";
161
+ });
162
+ afterEach(() => {
163
+ resetEnv();
164
+ });
165
+
166
+ test("rejects mount src outside whitelist", async () => {
167
+ const sandbox = createSandbox({ mountWhitelist: ["/srv/agent"] });
168
+ await expect(
169
+ sandbox.exec({
170
+ image: "alpine:3.19",
171
+ argv: ["true"],
172
+ mounts: [{ src: "/etc", dst: "/etc-mounted" }],
173
+ }),
174
+ ).rejects.toThrow(/not under any whitelisted root/);
175
+ });
176
+
177
+ test("rejects relative mount src", async () => {
178
+ const sandbox = createSandbox({ mountWhitelist: ["/srv/agent"] });
179
+ await expect(
180
+ sandbox.exec({
181
+ image: "alpine:3.19",
182
+ argv: ["true"],
183
+ mounts: [{ src: "../etc", dst: "/etc-mounted" }],
184
+ }),
185
+ ).rejects.toThrow(/absolute/);
186
+ });
187
+
188
+ test("rejects mount path with traversal segment", async () => {
189
+ const sandbox = createSandbox({ mountWhitelist: ["/srv/agent"] });
190
+ await expect(
191
+ sandbox.exec({
192
+ image: "alpine:3.19",
193
+ argv: ["true"],
194
+ mounts: [{ src: "/srv/agent/../etc", dst: "/etc-mounted" }],
195
+ }),
196
+ ).rejects.toThrow(/may not contain "\.\."/);
197
+ });
198
+
199
+ test("rejects newline in mount path", async () => {
200
+ const sandbox = createSandbox({ mountWhitelist: ["/srv/agent"] });
201
+ await expect(
202
+ sandbox.exec({
203
+ image: "alpine:3.19",
204
+ argv: ["true"],
205
+ mounts: [{ src: "/srv/agent\n--privileged", dst: "/x" }],
206
+ }),
207
+ ).rejects.toThrow(/newline|may not contain/);
208
+ });
209
+
210
+ test("accepts mount inside whitelist root", async () => {
211
+ const sandbox = createSandbox({ mountWhitelist: ["/srv/agent"] });
212
+ // Goes through validation; noop won't actually mount anything.
213
+ await sandbox.exec({
214
+ image: "alpine:3.19",
215
+ argv: ["printf", "ok"],
216
+ mounts: [{ src: "/srv/agent/data", dst: "/data" }],
217
+ });
218
+ });
219
+ });
220
+
221
+ describe("env-key validation", () => {
222
+ beforeEach(() => {
223
+ process.env["CREWHAUS_SANDBOX"] = "noop";
224
+ });
225
+ afterEach(() => {
226
+ resetEnv();
227
+ });
228
+
229
+ test("rejects invalid env key", async () => {
230
+ const sandbox = createSandbox();
231
+ await expect(
232
+ sandbox.exec({
233
+ image: "alpine:3.19",
234
+ argv: ["true"],
235
+ env: { "FOO BAR": "1" },
236
+ }),
237
+ ).rejects.toThrow(/not a valid identifier/);
238
+ });
239
+
240
+ test("accepts well-formed env key", async () => {
241
+ const sandbox = createSandbox();
242
+ await sandbox.exec({
243
+ image: "alpine:3.19",
244
+ argv: ["printf", "ok"],
245
+ env: { FOO_BAR: "1" },
246
+ });
247
+ });
248
+ });
249
+
250
+ describe("close", () => {
251
+ beforeEach(() => {
252
+ process.env["CREWHAUS_SANDBOX"] = "noop";
253
+ });
254
+ afterEach(() => {
255
+ resetEnv();
256
+ });
257
+
258
+ test("close prevents further exec", async () => {
259
+ const sandbox = createSandbox();
260
+ await sandbox.close();
261
+ await expect(sandbox.exec({ image: "alpine:3.19", argv: ["true"] })).rejects.toThrow(/closed/);
262
+ });
263
+
264
+ test("close is idempotent", async () => {
265
+ const sandbox = createSandbox();
266
+ await sandbox.close();
267
+ await sandbox.close();
268
+ });
269
+ });
270
+
271
+ describe("docker backend (no daemon required for argv assembly)", () => {
272
+ beforeEach(() => {
273
+ resetEnv();
274
+ });
275
+ afterEach(() => {
276
+ resetEnv();
277
+ });
278
+
279
+ test("validates image on docker backend before invoking docker", async () => {
280
+ const sandbox = createSandbox({ backend: "docker" });
281
+ await expect(sandbox.exec({ image: "evil:latest", argv: ["true"] })).rejects.toThrow(
282
+ /not on the allowlist/,
283
+ );
284
+ });
285
+
286
+ test("validates mount on docker backend before invoking docker", async () => {
287
+ const sandbox = createSandbox({ backend: "docker", mountWhitelist: ["/srv/agent"] });
288
+ await expect(
289
+ sandbox.exec({
290
+ image: "alpine:3.19",
291
+ argv: ["true"],
292
+ mounts: [{ src: "/etc", dst: "/etc" }],
293
+ }),
294
+ ).rejects.toThrow(/not under any whitelisted root/);
295
+ });
296
+ });
package/src/index.ts ADDED
@@ -0,0 +1,433 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+
3
+ /**
4
+ * Catalog R8 `sandbox` — containerised exec environment.
5
+ *
6
+ * Backends:
7
+ * docker — production default; assumes `docker` daemon reachable.
8
+ * podman — drop-in replacement that swaps the CLI binary.
9
+ * noop — in-process exec (NOT a security boundary). Test-only;
10
+ * must be opted in via `CREWHAUS_SANDBOX=noop`. The
11
+ * permission engine refuses to satisfy `requiresSandbox`
12
+ * tools when this backend is active.
13
+ *
14
+ * Defaults applied to every container:
15
+ * --network none
16
+ * --memory 512m
17
+ * --cpus 1.0
18
+ * --read-only
19
+ * --tmpfs /tmp:rw,size=64m,mode=1777,exec
20
+ * 60 second wall-clock timeout
21
+ *
22
+ * Image allowlist: any image string requested by `exec()` must appear
23
+ * in the constructor's `allowedImages` set OR in
24
+ * `CREWHAUS_SANDBOX_ALLOWED_IMAGES` (comma-separated). If neither is
25
+ * set, only the curated default list is allowed:
26
+ * - python:3.13-slim
27
+ * - node:22-alpine
28
+ * - alpine:3.19
29
+ *
30
+ * Mount whitelist: callers pass `mounts: ReadonlyArray<{src,dst,readonly?}>`,
31
+ * but only `src` paths inside `mountWhitelist` (or under `process.cwd()`
32
+ * by default) are accepted. Path-traversal attempts (`..` segments,
33
+ * non-absolute `src`) throw before docker is invoked.
34
+ *
35
+ * SECURITY: image strings and command strings are passed as separate
36
+ * `Bun.spawn` argv elements, so shell metacharacters (`;`, `&&`, `$()`)
37
+ * cannot escape the docker run invocation. Image and mount values are
38
+ * additionally screened for line-feed and dash-prefix tampering before
39
+ * the spawn so an attacker cannot smuggle CLI flags via input.
40
+ *
41
+ * Layer R8 (production safety floor). Pairs with `tool-code-execution`
42
+ * (R4) and `permission-engine` (R8 — the `requiresSandbox` floor).
43
+ */
44
+
45
+ export type SandboxBackend = "docker" | "podman" | "noop";
46
+
47
+ export type SandboxMount = {
48
+ /** Absolute host path. Must be inside `mountWhitelist` or cwd. */
49
+ readonly src: string;
50
+ /** Absolute container path. */
51
+ readonly dst: string;
52
+ /** Defaults to true (read-only mount). */
53
+ readonly readonly?: boolean;
54
+ };
55
+
56
+ export type SandboxOptions = {
57
+ /** Defaults to env `CREWHAUS_SANDBOX` (then "docker"). */
58
+ readonly backend?: SandboxBackend;
59
+ /** Non-empty subset of permitted images. Empty = use defaults+env. */
60
+ readonly allowedImages?: ReadonlyArray<string>;
61
+ /** Absolute paths under which `mounts.src` may live. Defaults to [cwd]. */
62
+ readonly mountWhitelist?: ReadonlyArray<string>;
63
+ /** Default exec timeout. Per-call timeout overrides this. */
64
+ readonly defaultTimeoutMs?: number;
65
+ /** Memory cap, e.g. "512m". */
66
+ readonly memory?: string;
67
+ /** CPU cap, e.g. "1.0". */
68
+ readonly cpus?: string;
69
+ /** When true, network is allowed (default false). Smoke checks rely on this default. */
70
+ readonly network?: boolean;
71
+ };
72
+
73
+ export type SandboxExecOptions = {
74
+ /** Container image (e.g. "python:3.13-slim"). Must be in allowlist. */
75
+ readonly image: string;
76
+ /** Argv form — passed as separate args to the interpreter. */
77
+ readonly argv: ReadonlyArray<string>;
78
+ /** Optional: piped to the container's stdin. */
79
+ readonly stdin?: string;
80
+ /** Optional: extra env vars (key/value, no shell interpolation). */
81
+ readonly env?: Readonly<Record<string, string>>;
82
+ /** Per-call mount additions; src must pass the whitelist. */
83
+ readonly mounts?: ReadonlyArray<SandboxMount>;
84
+ /** Override the sandbox's default timeout. */
85
+ readonly timeoutMs?: number;
86
+ /** Optional cooperative cancellation. */
87
+ readonly signal?: AbortSignal;
88
+ /** Forwarded line-by-line for streaming consumers. */
89
+ readonly onStdoutChunk?: (chunk: string) => void;
90
+ readonly onStderrChunk?: (chunk: string) => void;
91
+ };
92
+
93
+ export type SandboxExecResult = {
94
+ readonly stdout: string;
95
+ readonly stderr: string;
96
+ readonly exitCode: number;
97
+ readonly timedOut: boolean;
98
+ readonly durationMs: number;
99
+ };
100
+
101
+ export class SandboxError extends CrewhausError {
102
+ override readonly name = "SandboxError";
103
+ constructor(message: string, cause?: unknown) {
104
+ super("config", message, cause);
105
+ }
106
+ }
107
+
108
+ export interface Sandbox {
109
+ readonly backend: SandboxBackend;
110
+ exec(opts: SandboxExecOptions): Promise<SandboxExecResult>;
111
+ /** Idempotent. */
112
+ close(): Promise<void>;
113
+ }
114
+
115
+ const DEFAULT_ALLOWED_IMAGES: ReadonlyArray<string> = [
116
+ "python:3.13-slim",
117
+ "node:22-alpine",
118
+ "alpine:3.19",
119
+ ];
120
+
121
+ const DEFAULT_TIMEOUT_MS = 60_000;
122
+ const DEFAULT_MEMORY = "512m";
123
+ const DEFAULT_CPUS = "1.0";
124
+
125
+ /**
126
+ * Image strings must be `repository[:tag][@digest]`. We disallow leading
127
+ * dashes (CLI flag injection), whitespace (newline-injection), and shell
128
+ * metacharacters even though we never pass them to a shell — defense in
129
+ * depth.
130
+ */
131
+ const IMAGE_RE = /^[a-z0-9][a-z0-9._\-/]*(?::[a-zA-Z0-9._\-]+)?(?:@sha256:[a-f0-9]{64})?$/;
132
+
133
+ function readEnvBackend(): SandboxBackend | undefined {
134
+ const raw = (process.env["CREWHAUS_SANDBOX"] ?? "").trim().toLowerCase();
135
+ if (raw === "") return undefined;
136
+ if (raw === "docker" || raw === "podman" || raw === "noop") return raw;
137
+ throw new SandboxError(`CREWHAUS_SANDBOX=${raw} is not one of docker|podman|noop`);
138
+ }
139
+
140
+ function readEnvAllowedImages(): ReadonlyArray<string> {
141
+ const raw = process.env["CREWHAUS_SANDBOX_ALLOWED_IMAGES"];
142
+ if (raw === undefined || raw.trim() === "") return [];
143
+ return raw
144
+ .split(",")
145
+ .map((s) => s.trim())
146
+ .filter((s) => s.length > 0);
147
+ }
148
+
149
+ function validateImage(image: string, allow: ReadonlySet<string>): void {
150
+ if (image.length === 0) throw new SandboxError("image is required");
151
+ if (image.startsWith("-")) {
152
+ throw new SandboxError(`image "${image}" looks like a CLI flag — refused`);
153
+ }
154
+ if (image.includes("\n") || image.includes(" ") || image.includes("\t")) {
155
+ throw new SandboxError(`image "${image}" contains whitespace — refused`);
156
+ }
157
+ if (!IMAGE_RE.test(image)) {
158
+ throw new SandboxError(`image "${image}" is not a valid registry reference`);
159
+ }
160
+ if (!allow.has(image)) {
161
+ const list = [...allow].sort().join(", ");
162
+ throw new SandboxError(
163
+ `image "${image}" is not on the allowlist — allowed: ${list || "(empty)"}`,
164
+ );
165
+ }
166
+ }
167
+
168
+ function validateMount(m: SandboxMount, whitelist: ReadonlyArray<string>): void {
169
+ if (!m.src.startsWith("/")) {
170
+ throw new SandboxError(`mount src "${m.src}" must be absolute`);
171
+ }
172
+ if (!m.dst.startsWith("/")) {
173
+ throw new SandboxError(`mount dst "${m.dst}" must be absolute`);
174
+ }
175
+ if (m.src.includes("..") || m.dst.includes("..")) {
176
+ throw new SandboxError(`mount path may not contain ".." (src="${m.src}", dst="${m.dst}")`);
177
+ }
178
+ if (m.src.includes("\n") || m.dst.includes("\n")) {
179
+ throw new SandboxError("mount path may not contain newlines");
180
+ }
181
+ const ok = whitelist.some((root) => m.src === root || m.src.startsWith(`${root}/`));
182
+ if (!ok) {
183
+ const roots = whitelist.join(", ");
184
+ throw new SandboxError(`mount src "${m.src}" is not under any whitelisted root (${roots})`);
185
+ }
186
+ }
187
+
188
+ function validateEnvKey(key: string): void {
189
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
190
+ throw new SandboxError(`env key "${key}" is not a valid identifier`);
191
+ }
192
+ }
193
+
194
+ class DockerLikeSandbox implements Sandbox {
195
+ readonly backend: SandboxBackend;
196
+ private readonly cli: string;
197
+ private readonly allowedImages: ReadonlySet<string>;
198
+ private readonly mountWhitelist: ReadonlyArray<string>;
199
+ private readonly defaultTimeoutMs: number;
200
+ private readonly memory: string;
201
+ private readonly cpus: string;
202
+ private readonly network: boolean;
203
+ private closed = false;
204
+
205
+ constructor(backend: "docker" | "podman", opts: SandboxOptions) {
206
+ this.backend = backend;
207
+ this.cli = backend;
208
+ const ownAllowed = (opts.allowedImages ?? []).filter((s) => s.length > 0);
209
+ const envAllowed = readEnvAllowedImages();
210
+ const merged = new Set<string>(
211
+ ownAllowed.length > 0 || envAllowed.length > 0
212
+ ? [...ownAllowed, ...envAllowed]
213
+ : DEFAULT_ALLOWED_IMAGES,
214
+ );
215
+ this.allowedImages = merged;
216
+ this.mountWhitelist = (opts.mountWhitelist ?? [process.cwd()]).map((p) => {
217
+ if (!p.startsWith("/")) {
218
+ throw new SandboxError(`mountWhitelist entry "${p}" must be absolute`);
219
+ }
220
+ return p;
221
+ });
222
+ this.defaultTimeoutMs = opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
223
+ this.memory = opts.memory ?? DEFAULT_MEMORY;
224
+ this.cpus = opts.cpus ?? DEFAULT_CPUS;
225
+ this.network = opts.network === true;
226
+ }
227
+
228
+ async exec(opts: SandboxExecOptions): Promise<SandboxExecResult> {
229
+ if (this.closed) throw new SandboxError("sandbox is closed");
230
+ validateImage(opts.image, this.allowedImages);
231
+ const mounts = opts.mounts ?? [];
232
+ for (const m of mounts) validateMount(m, this.mountWhitelist);
233
+
234
+ const timeoutMs = opts.timeoutMs ?? this.defaultTimeoutMs;
235
+ const cliArgs: string[] = [
236
+ "run",
237
+ "--rm",
238
+ "-i",
239
+ this.network ? "--network=bridge" : "--network=none",
240
+ `--memory=${this.memory}`,
241
+ `--cpus=${this.cpus}`,
242
+ "--read-only",
243
+ "--tmpfs",
244
+ "/tmp:rw,size=64m,mode=1777,exec",
245
+ "--security-opt",
246
+ "no-new-privileges",
247
+ ];
248
+ for (const m of mounts) {
249
+ const ro = m.readonly !== false;
250
+ cliArgs.push("-v", `${m.src}:${m.dst}${ro ? ":ro" : ""}`);
251
+ }
252
+ if (opts.env !== undefined) {
253
+ for (const [k, v] of Object.entries(opts.env)) {
254
+ validateEnvKey(k);
255
+ cliArgs.push("-e", `${k}=${v}`);
256
+ }
257
+ }
258
+ cliArgs.push(opts.image, ...opts.argv);
259
+
260
+ const t0 = performance.now();
261
+ const proc = Bun.spawn([this.cli, ...cliArgs], {
262
+ stdin: "pipe",
263
+ stdout: "pipe",
264
+ stderr: "pipe",
265
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
266
+ });
267
+
268
+ if (opts.stdin !== undefined) {
269
+ proc.stdin.write(opts.stdin);
270
+ }
271
+ proc.stdin.end();
272
+
273
+ let timedOut = false;
274
+ const timer = setTimeout(() => {
275
+ timedOut = true;
276
+ try {
277
+ proc.kill("SIGKILL");
278
+ } catch {
279
+ // already exited
280
+ }
281
+ }, timeoutMs);
282
+
283
+ try {
284
+ const stdoutP = collectStream(proc.stdout, opts.onStdoutChunk);
285
+ const stderrP = collectStream(proc.stderr, opts.onStderrChunk);
286
+ const exitCode = await proc.exited;
287
+ const stdout = await stdoutP;
288
+ const stderr = await stderrP;
289
+ return {
290
+ stdout,
291
+ stderr,
292
+ exitCode,
293
+ timedOut,
294
+ durationMs: performance.now() - t0,
295
+ };
296
+ } finally {
297
+ clearTimeout(timer);
298
+ }
299
+ }
300
+
301
+ async close(): Promise<void> {
302
+ this.closed = true;
303
+ }
304
+ }
305
+
306
+ class NoopSandbox implements Sandbox {
307
+ readonly backend: SandboxBackend = "noop";
308
+ private readonly allowedImages: ReadonlySet<string>;
309
+ private readonly mountWhitelist: ReadonlyArray<string>;
310
+ private readonly defaultTimeoutMs: number;
311
+ private closed = false;
312
+
313
+ constructor(opts: SandboxOptions = {}) {
314
+ const ownAllowed = (opts.allowedImages ?? []).filter((s) => s.length > 0);
315
+ const envAllowed = readEnvAllowedImages();
316
+ const merged = new Set<string>(
317
+ ownAllowed.length > 0 || envAllowed.length > 0
318
+ ? [...ownAllowed, ...envAllowed]
319
+ : DEFAULT_ALLOWED_IMAGES,
320
+ );
321
+ this.allowedImages = merged;
322
+ this.mountWhitelist = (opts.mountWhitelist ?? [process.cwd()]).map((p) => {
323
+ if (!p.startsWith("/")) {
324
+ throw new SandboxError(`mountWhitelist entry "${p}" must be absolute`);
325
+ }
326
+ return p;
327
+ });
328
+ this.defaultTimeoutMs = opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
329
+ }
330
+
331
+ async exec(opts: SandboxExecOptions): Promise<SandboxExecResult> {
332
+ if (this.closed) throw new SandboxError("sandbox is closed");
333
+ validateImage(opts.image, this.allowedImages);
334
+ const mounts = opts.mounts ?? [];
335
+ for (const m of mounts) validateMount(m, this.mountWhitelist);
336
+ if (opts.env !== undefined) {
337
+ for (const k of Object.keys(opts.env)) validateEnvKey(k);
338
+ }
339
+
340
+ // The noop backend runs the requested argv directly via Bun.spawn —
341
+ // there is NO isolation. It exists to make unit tests deterministic
342
+ // without a docker daemon. Production paths reject this backend at
343
+ // the permission layer (`requiresSandbox` denial).
344
+ const t0 = performance.now();
345
+ const argv = [...opts.argv];
346
+ const env =
347
+ opts.env !== undefined
348
+ ? ({ ...process.env, ...opts.env } as Record<string, string>)
349
+ : undefined;
350
+ const proc = Bun.spawn(argv, {
351
+ stdin: "pipe",
352
+ stdout: "pipe",
353
+ stderr: "pipe",
354
+ ...(env !== undefined ? { env } : {}),
355
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
356
+ });
357
+
358
+ if (opts.stdin !== undefined) {
359
+ proc.stdin.write(opts.stdin);
360
+ }
361
+ proc.stdin.end();
362
+
363
+ let timedOut = false;
364
+ const timer = setTimeout(() => {
365
+ timedOut = true;
366
+ try {
367
+ proc.kill("SIGKILL");
368
+ } catch {
369
+ // already exited
370
+ }
371
+ }, opts.timeoutMs ?? this.defaultTimeoutMs);
372
+ try {
373
+ const stdoutP = collectStream(proc.stdout, opts.onStdoutChunk);
374
+ const stderrP = collectStream(proc.stderr, opts.onStderrChunk);
375
+ const exitCode = await proc.exited;
376
+ const stdout = await stdoutP;
377
+ const stderr = await stderrP;
378
+ return {
379
+ stdout,
380
+ stderr,
381
+ exitCode,
382
+ timedOut,
383
+ durationMs: performance.now() - t0,
384
+ };
385
+ } finally {
386
+ clearTimeout(timer);
387
+ }
388
+ }
389
+
390
+ async close(): Promise<void> {
391
+ this.closed = true;
392
+ }
393
+ }
394
+
395
+ async function collectStream(
396
+ stream: ReadableStream<Uint8Array> | undefined,
397
+ onChunk?: (chunk: string) => void,
398
+ ): Promise<string> {
399
+ if (stream === undefined) return "";
400
+ // When no streaming consumer is attached, take the fast path through
401
+ // Response.text() which Bun has tuned for spawned-process pipes.
402
+ if (onChunk === undefined) {
403
+ return await new Response(stream).text();
404
+ }
405
+ const decoder = new TextDecoder();
406
+ const reader = stream.getReader();
407
+ let acc = "";
408
+ try {
409
+ while (true) {
410
+ const { value, done } = await reader.read();
411
+ if (done) break;
412
+ const chunk = decoder.decode(value, { stream: true });
413
+ acc += chunk;
414
+ if (chunk.length > 0) onChunk(chunk);
415
+ }
416
+ const tail = decoder.decode();
417
+ if (tail.length > 0) {
418
+ acc += tail;
419
+ onChunk(tail);
420
+ }
421
+ } finally {
422
+ reader.releaseLock();
423
+ }
424
+ return acc;
425
+ }
426
+
427
+ export function createSandbox(opts: SandboxOptions = {}): Sandbox {
428
+ const backend = opts.backend ?? readEnvBackend() ?? "docker";
429
+ if (backend === "noop") return new NoopSandbox(opts);
430
+ return new DockerLikeSandbox(backend, opts);
431
+ }
432
+
433
+ export const SANDBOX_DEFAULT_ALLOWED_IMAGES = DEFAULT_ALLOWED_IMAGES;