@chengchenccc/sandbox 0.1.1-rc.2 → 0.2.0-rc.1

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/dist/index.d.ts CHANGED
@@ -24,6 +24,21 @@ export interface SandboxInput {
24
24
  cwd?: string;
25
25
  /** Keep the working dir after the run (default: removed). */
26
26
  keepCwd?: boolean;
27
+ /** H2: OS-level isolation knobs. When requested and a wrapper tool is
28
+ * available (Linux: bwrap, macOS: sandbox-exec) the subprocess runs
29
+ * inside it; without a tool the run falls back to process isolation
30
+ * only, with a warning naming the ceiling. */
31
+ isolation?: {
32
+ /** Cut all network access (unshare-net / deny network*). */
33
+ noNetwork?: boolean;
34
+ /** Directories the script must not read: overlaid with an empty tmpfs
35
+ * (bwrap) or denied by profile (sandbox-exec). Non-existent dirs are
36
+ * skipped. */
37
+ denyReadDirs?: readonly string[];
38
+ };
39
+ /** Abort kills the process tree immediately (the run resolves with a
40
+ * non-zero exit, timedOut stays false). */
41
+ signal?: AbortSignal;
27
42
  }
28
43
  export interface SandboxResult {
29
44
  /** The script's returned value (parsed JSON), or null when it returned nothing. */
@@ -35,6 +50,10 @@ export interface SandboxResult {
35
50
  exitCode: number;
36
51
  timedOut: boolean;
37
52
  }
53
+ /** Compose the spawn argv for one sandboxed process: platform wrapper
54
+ * (setsid/bwrap/sandbox-exec) around the base command. Shared by the
55
+ * one-shot runner and persistent sessions. */
56
+ export declare function buildWrappedArgv(baseArgv: readonly string[], isolation: SandboxInput["isolation"] | undefined, dir: string): string[];
38
57
  export declare function runInSandbox(input: SandboxInput): Promise<SandboxResult>;
39
58
  export declare class SandboxTimeoutError extends Error {
40
59
  constructor(timeoutMs: number);
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;wEAYwE;AAMxE,MAAM,WAAW,YAAY;IAC3B,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,8EAA8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,mFAAmF;IACnF,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACvC,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAoCD,wBAAsB,YAAY,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,CAgD9E;AAED,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,SAAS,EAAE,MAAM;CAI9B"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;wEAYwE;AAKxE,MAAM,WAAW,YAAY;IAC3B,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,8EAA8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;mDAG+C;IAC/C,SAAS,CAAC,EAAE;QACV,4DAA4D;QAC5D,SAAS,CAAC,EAAE,OAAO,CAAC;QACpB;;uBAEe;QACf,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;KAClC,CAAC;IACF;gDAC4C;IAC5C,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,mFAAmF;IACnF,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACvC,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAuHD;;+CAE+C;AAC/C,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,SAAS,MAAM,EAAE,EAC3B,SAAS,EAAE,YAAY,CAAC,WAAW,CAAC,GAAG,SAAS,EAChD,GAAG,EAAE,MAAM,GACV,MAAM,EAAE,CAsBV;AAED,wBAAsB,YAAY,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,CA8G9E;AAED,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,SAAS,EAAE,MAAM;CAI9B"}
package/dist/index.js CHANGED
@@ -11,9 +11,9 @@
11
11
  * no access to host objects). It is NOT a filesystem/network jail — a
12
12
  * hostile script can still touch the host filesystem like any spawned
13
13
  * process. Container-level isolation is a deliberate non-goal here. */
14
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
14
+ import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
15
15
  import { tmpdir } from "node:os";
16
- import { join, resolve } from "node:path";
16
+ import { dirname, join, resolve } from "node:path";
17
17
  const BASE_ENV = {
18
18
  PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
19
19
  HOME: tmpdir(),
@@ -46,33 +46,198 @@ try {
46
46
  process.exit(1);
47
47
  }
48
48
  `;
49
+ function bwrapArgv(base, isolation, selfBinDir) {
50
+ const args = [
51
+ "bwrap",
52
+ "--dev-bind",
53
+ "/",
54
+ "/",
55
+ // Fresh PID namespace + procfs: the script cannot see, signal, or
56
+ // enumerate host processes (the backend included).
57
+ "--unshare-pid",
58
+ "--proc",
59
+ "/proc",
60
+ ];
61
+ if (isolation?.noNetwork)
62
+ args.push("--unshare-net");
63
+ // Hide the sandbox user's home dirs: the script runs as the SAME user,
64
+ // so home is the highest-value read/write surface. The bun binary often
65
+ // lives under the home — re-expose its bin dir read-only so PATH lookup
66
+ // and exec keep working.
67
+ args.push("--tmpfs", "/root", "--tmpfs", "/home", "--ro-bind", selfBinDir, selfBinDir);
68
+ for (const d of isolation?.denyReadDirs ?? []) {
69
+ if (!existsSync(d))
70
+ continue;
71
+ // Directory: empty tmpfs shadows content (reads AND writes). File
72
+ // (e.g. the deployment .env): /dev/null overlay empties its reads.
73
+ if (statSync(d).isDirectory())
74
+ args.push("--tmpfs", d);
75
+ else
76
+ args.push("--ro-bind", "/dev/null", d);
77
+ }
78
+ args.push(...base);
79
+ return args;
80
+ }
81
+ function sandboxExecArgv(base, dir, isolation) {
82
+ const denies = (isolation?.denyReadDirs ?? [])
83
+ .map((d) => `(deny file-read-data (subpath "${d}"))`)
84
+ .join("");
85
+ const net = isolation?.noNetwork ? "(deny network*)" : "";
86
+ const profilePath = join(dir, "__sandbox_profile.sb");
87
+ writeFileSync(profilePath, `(version 1)(allow default)${net}${denies}`);
88
+ return ["sandbox-exec", "-f", profilePath, ...base];
89
+ }
90
+ /** Post-exit drain grace: fires only when the pipe is still open (an
91
+ * orphaned grandchild holds it) — buffered data resolves instantly. */
92
+ function stallAfter(ms) {
93
+ const { promise, resolve } = Promise.withResolvers();
94
+ const t = setTimeout(() => resolve("stalled"), ms);
95
+ t.unref?.();
96
+ return promise;
97
+ }
98
+ /** Depth-first descendant reap for platforms without setsid (H3): pgrep -P
99
+ * per generation, grandchildren killed before their parents so nothing
100
+ * re-parents and survives. Best effort — a missing pgrep degrades to the
101
+ * direct-child kill only. */
102
+ function killDescendants(pid, signal) {
103
+ let kids;
104
+ try {
105
+ kids = Bun.spawnSync(["pgrep", "-P", String(pid)])
106
+ .stdout.toString()
107
+ .split("\n")
108
+ .flatMap((line) => {
109
+ const n = Number(line.trim());
110
+ return Number.isInteger(n) && n > 0 ? [n] : [];
111
+ });
112
+ }
113
+ catch {
114
+ return;
115
+ }
116
+ for (const kid of kids)
117
+ killDescendants(kid, signal);
118
+ for (const kid of kids) {
119
+ try {
120
+ process.kill(kid, signal);
121
+ }
122
+ catch {
123
+ /* already gone */
124
+ }
125
+ }
126
+ }
127
+ /** Compose the spawn argv for one sandboxed process: platform wrapper
128
+ * (setsid/bwrap/sandbox-exec) around the base command. Shared by the
129
+ * one-shot runner and persistent sessions. */
130
+ export function buildWrappedArgv(baseArgv, isolation, dir) {
131
+ const hasSetsid = Bun.which("setsid") !== null;
132
+ const wantsNetworkCut = isolation?.noNetwork === true;
133
+ const wantsDenyRead = (isolation?.denyReadDirs?.length ?? 0) > 0;
134
+ const isolationRequested = wantsNetworkCut || wantsDenyRead;
135
+ const onLinux = process.platform === "linux";
136
+ const onMacos = process.platform === "darwin";
137
+ const hasBwrap = Bun.which("bwrap") !== null;
138
+ const hasSandboxExec = Bun.which("sandbox-exec") !== null;
139
+ let argv = [...baseArgv];
140
+ if (isolationRequested) {
141
+ if (onLinux && hasBwrap) {
142
+ argv = bwrapArgv(argv, isolation, dirname(process.execPath));
143
+ }
144
+ else if (onMacos && hasSandboxExec) {
145
+ argv = sandboxExecArgv(argv, dir, isolation);
146
+ }
147
+ else {
148
+ console.warn("[sandbox] isolation requested but no bwrap/sandbox-exec available — running with process isolation only");
149
+ }
150
+ }
151
+ return hasSetsid ? ["setsid", ...argv] : argv;
152
+ }
49
153
  export async function runInSandbox(input) {
50
- const timeoutMs = input.timeoutMs ?? 30_000;
154
+ // pi semantics: 0 disables the deadline entirely (a cell may legitimately
155
+ // run long); undefined keeps the historical 30s default.
156
+ const timeoutMs = input.timeoutMs === 0 ? 0 : (input.timeoutMs ?? 30_000);
157
+ const hasDeadline = timeoutMs > 0;
51
158
  const dir = input.cwd ?? mkTempDir();
52
159
  mkdirSync(dir, { recursive: true });
53
160
  writeFileSync(join(dir, "script.ts"), input.code);
54
161
  writeFileSync(join(dir, "__sandbox_main.ts"), WRAPPER);
55
162
  let timedOut = false;
163
+ // Cap buffered output: a hostile `while(true) console.log(...)` would
164
+ // otherwise OOM the backend before the timeout fires.
165
+ const MAX_STREAM_BYTES = 10 * 1024 * 1024;
166
+ async function cappedText(stream) {
167
+ const reader = stream.getReader();
168
+ const chunks = [];
169
+ let total = 0;
170
+ let exitedSeen = false;
171
+ for (;;) {
172
+ const read = reader.read();
173
+ // During the run: wait on data OR process exit. Losing to exit means
174
+ // EOF is pending (H3: an orphaned grandchild inheriting the pipe can
175
+ // hold it open forever) — switch to the bounded drain below.
176
+ const res = exitedSeen
177
+ ? await Promise.race([read, stallAfter(1_000)])
178
+ : await Promise.race([read, exited.then(() => "exited")]);
179
+ if (res === "exited") {
180
+ exitedSeen = true;
181
+ continue;
182
+ }
183
+ if (res === "stalled" || res.done) {
184
+ // Post-exit the kernel buffer delivers instantly; stalling past the
185
+ // grace means an orphan still holds the pipe — cancel and use what
186
+ // we got instead of hanging on EOF.
187
+ if (res === "stalled")
188
+ reader.cancel().catch(() => { });
189
+ break;
190
+ }
191
+ total += res.value.byteLength;
192
+ if (total <= MAX_STREAM_BYTES)
193
+ chunks.push(res.value);
194
+ }
195
+ const buf = Buffer.concat(chunks).toString("utf8");
196
+ return total > MAX_STREAM_BYTES
197
+ ? `${buf.slice(0, MAX_STREAM_BYTES)}\n[sandbox: output truncated at ${MAX_STREAM_BYTES} bytes]`
198
+ : buf;
199
+ }
200
+ const argv = buildWrappedArgv(["bun", "run", join(dir, "__sandbox_main.ts")], input.isolation, dir);
201
+ const proc = Bun.spawn(argv, {
202
+ cwd: dir,
203
+ env: { ...BASE_ENV, ...(input.env ?? {}) },
204
+ stdin: "pipe",
205
+ stdout: "pipe",
206
+ stderr: "pipe",
207
+ });
208
+ proc.stdin.write(JSON.stringify(input.input ?? {}));
209
+ proc.stdin.end();
210
+ const exited = proc.exited;
56
211
  try {
57
- const proc = Bun.spawn(["bun", "run", join(dir, "__sandbox_main.ts")], {
58
- cwd: dir,
59
- env: { ...BASE_ENV, ...(input.env ?? {}) },
60
- stdin: "pipe",
61
- stdout: "pipe",
62
- stderr: "pipe",
63
- });
64
- proc.stdin.write(JSON.stringify(input.input ?? {}));
65
- proc.stdin.end();
66
- const timer = setTimeout(() => {
67
- timedOut = true;
68
- proc.kill();
69
- }, timeoutMs);
70
- const [stdout, stderr, exitCode] = await Promise.all([
71
- new Response(proc.stdout).text(),
72
- new Response(proc.stderr).text(),
73
- proc.exited,
74
- ]);
212
+ const killTree = (signal) => {
213
+ // Group kill first (setsid made the child a leader); fall back to
214
+ // direct child + a depth-first descendant reap when no group exists
215
+ // (macOS has no setsid).
216
+ try {
217
+ process.kill(-proc.pid, signal);
218
+ }
219
+ catch {
220
+ proc.kill(signal);
221
+ killDescendants(proc.pid, signal);
222
+ }
223
+ };
224
+ let escalation;
225
+ const timer = hasDeadline
226
+ ? setTimeout(() => {
227
+ timedOut = true;
228
+ killTree("SIGTERM");
229
+ escalation = setTimeout(() => killTree("SIGKILL"), 2_000);
230
+ escalation.unref?.();
231
+ }, timeoutMs)
232
+ : undefined;
233
+ // H3: completion is gated on proc.exited, NEVER on pipe EOF.
234
+ const exitCode = await exited;
75
235
  clearTimeout(timer);
236
+ clearTimeout(escalation);
237
+ const [stdout, stderr] = await Promise.all([
238
+ cappedText(proc.stdout),
239
+ cappedText(proc.stderr),
240
+ ]);
76
241
  const marker = stdout.lastIndexOf("__SANDBOX_OUTPUT__:");
77
242
  let output = null;
78
243
  let cleanStdout = stdout;
@@ -1,4 +1,7 @@
1
1
  import { expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { runInSandbox } from "./index.js";
3
6
  test("runs a script and returns its output", async () => {
4
7
  const r = await runInSandbox({
@@ -34,6 +37,26 @@ test("timeout kills the process", async () => {
34
37
  timeoutMs: 1_000,
35
38
  })).rejects.toThrow(/timed out/);
36
39
  });
40
+ const HAS_SETSID = Bun.which("setsid") !== null;
41
+ test.skipIf(!HAS_SETSID)("timeout throws even when a detached grandchild holds stdout (H3)", async () => {
42
+ // `setsid sleep` escapes the process group and inherits our stdout
43
+ // pipe: EOF never arrives after the tree kill, so the old EOF-gated
44
+ // Promise.all hung until the daemon exited. Completion is now gated
45
+ // on proc.exited with a bounded post-exit drain.
46
+ const start = Date.now();
47
+ await expect(runInSandbox({
48
+ // Explicit inherit: the daemon's stdout IS the sandbox's pipe (a
49
+ // default-stdio spawn gets its own pipe and holds nothing).
50
+ code: `export default async () => {
51
+ const daemon = Bun.spawn(["setsid", "sleep", "5"], { stdout: "inherit", stderr: "inherit" });
52
+ await daemon.exited;
53
+ return { ok: true };
54
+ };`,
55
+ input: {},
56
+ timeoutMs: 1_500,
57
+ })).rejects.toThrow(/timed out/);
58
+ expect(Date.now() - start).toBeLessThan(5_000);
59
+ }, 30_000);
37
60
  test("minimal env — no host env leakage by default", async () => {
38
61
  const r = await runInSandbox({
39
62
  code: `export default async () => ({ hasSecret: typeof process.env.SANDBOX_LEAK_TEST !== "undefined" });`,
@@ -42,3 +65,43 @@ test("minimal env — no host env leakage by default", async () => {
42
65
  });
43
66
  expect(r.output).toEqual({ hasSecret: false });
44
67
  });
68
+ const HAS_BWRAP = Bun.which("bwrap") !== null;
69
+ test("isolation cuts network and hides denied dirs (H2)", async () => {
70
+ if (!HAS_BWRAP)
71
+ return;
72
+ const secretDir = mkdtempSync(join(tmpdir(), "sbx-secret-"));
73
+ writeFileSync(join(secretDir, "secret.txt"), "topsecret");
74
+ const r = await runInSandbox({
75
+ code: `export default async () => {
76
+ let netBlocked = false;
77
+ try {
78
+ await fetch("http://example.com/", { signal: AbortSignal.timeout(2_000) });
79
+ } catch {
80
+ netBlocked = true;
81
+ }
82
+ let readBlocked = false;
83
+ try {
84
+ await Bun.file(${JSON.stringify(join(secretDir, "secret.txt"))}).text();
85
+ } catch {
86
+ readBlocked = true;
87
+ }
88
+ return { netBlocked, readBlocked };
89
+ };`,
90
+ isolation: { noNetwork: true, denyReadDirs: [secretDir] },
91
+ timeoutMs: 20_000,
92
+ });
93
+ expect(r.exitCode).toBe(0);
94
+ expect(r.output).toEqual({ netBlocked: true, readBlocked: true });
95
+ rmSync(secretDir, { recursive: true, force: true });
96
+ }, 30_000);
97
+ test("isolation wrapper does not disturb normal execution (H2)", async () => {
98
+ if (!HAS_BWRAP)
99
+ return;
100
+ const r = await runInSandbox({
101
+ code: `export default async () => ({ echo: 41 + 1 });`,
102
+ isolation: { noNetwork: true, denyReadDirs: ["/nonexistent-h2"] },
103
+ timeoutMs: 20_000,
104
+ });
105
+ expect(r.exitCode).toBe(0);
106
+ expect(r.output).toEqual({ echo: 42 });
107
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chengchenccc/sandbox",
3
- "version": "0.1.1-rc.2",
3
+ "version": "0.2.0-rc.1",
4
4
  "description": "Process-isolated sandbox for executing untrusted workflow/eval scripts (Bun subprocess, timeout kill, JSON stdio)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -11,7 +11,7 @@
11
11
  "scripts": {
12
12
  "build": "tsc -p tsconfig.json",
13
13
  "lint": "biome check . && eslint .",
14
- "test": "bun test --pass-with-no-tests",
14
+ "test": "bun test src --pass-with-no-tests",
15
15
  "typecheck": "tsc -p tsconfig.json --noEmit"
16
16
  },
17
17
  "devDependencies": {},