@chengchenccc/sandbox 0.1.1-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 +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +44 -0
- package/package.json +23 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** @chengchenccc/sandbox — process-isolated execution of untrusted scripts.
|
|
2
|
+
*
|
|
3
|
+
* A script is a TS/JS module with `export default async (ctx) => output`.
|
|
4
|
+
* It runs in a spawned `bun` subprocess with its own temp directory as cwd,
|
|
5
|
+
* a minimal environment, a hard timeout (process tree kill), and a JSON
|
|
6
|
+
* stdio contract: input on stdin, output on the last stdout line marked
|
|
7
|
+
* `__SANDBOX_OUTPUT__`. Callers never share memory, modules, or handles
|
|
8
|
+
* with the script.
|
|
9
|
+
*
|
|
10
|
+
* Isolation level: process boundary (crash/resource/timeout isolation,
|
|
11
|
+
* no access to host objects). It is NOT a filesystem/network jail — a
|
|
12
|
+
* hostile script can still touch the host filesystem like any spawned
|
|
13
|
+
* process. Container-level isolation is a deliberate non-goal here. */
|
|
14
|
+
export interface SandboxInput {
|
|
15
|
+
/** The script source (TS/JS module). Must `export default (ctx) => output`. */
|
|
16
|
+
code: string;
|
|
17
|
+
/** JSON-serializable context passed to the script (stdin). */
|
|
18
|
+
input?: Record<string, unknown>;
|
|
19
|
+
/** Hard timeout in ms. Default 30_000. Kills the process tree. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
/** Extra env vars merged over the minimal base (PATH/HOME/LANG). */
|
|
22
|
+
env?: Record<string, string>;
|
|
23
|
+
/** Persistent dir for the script's files (cwd). Default: a fresh temp dir. */
|
|
24
|
+
cwd?: string;
|
|
25
|
+
/** Keep the working dir after the run (default: removed). */
|
|
26
|
+
keepCwd?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface SandboxResult {
|
|
29
|
+
/** The script's returned value (parsed JSON), or null when it returned nothing. */
|
|
30
|
+
output: Record<string, unknown> | null;
|
|
31
|
+
/** Raw stdout (without the output marker line). */
|
|
32
|
+
stdout: string;
|
|
33
|
+
/** Raw stderr. */
|
|
34
|
+
stderr: string;
|
|
35
|
+
exitCode: number;
|
|
36
|
+
timedOut: boolean;
|
|
37
|
+
}
|
|
38
|
+
export declare function runInSandbox(input: SandboxInput): Promise<SandboxResult>;
|
|
39
|
+
export declare class SandboxTimeoutError extends Error {
|
|
40
|
+
constructor(timeoutMs: number);
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** @chengchenccc/sandbox — process-isolated execution of untrusted scripts.
|
|
2
|
+
*
|
|
3
|
+
* A script is a TS/JS module with `export default async (ctx) => output`.
|
|
4
|
+
* It runs in a spawned `bun` subprocess with its own temp directory as cwd,
|
|
5
|
+
* a minimal environment, a hard timeout (process tree kill), and a JSON
|
|
6
|
+
* stdio contract: input on stdin, output on the last stdout line marked
|
|
7
|
+
* `__SANDBOX_OUTPUT__`. Callers never share memory, modules, or handles
|
|
8
|
+
* with the script.
|
|
9
|
+
*
|
|
10
|
+
* Isolation level: process boundary (crash/resource/timeout isolation,
|
|
11
|
+
* no access to host objects). It is NOT a filesystem/network jail — a
|
|
12
|
+
* hostile script can still touch the host filesystem like any spawned
|
|
13
|
+
* process. Container-level isolation is a deliberate non-goal here. */
|
|
14
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
const BASE_ENV = {
|
|
18
|
+
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
19
|
+
HOME: tmpdir(),
|
|
20
|
+
LANG: process.env.LANG ?? "en_US.UTF-8",
|
|
21
|
+
};
|
|
22
|
+
/** The wrapper that runs inside the subprocess. It reads ctx from stdin,
|
|
23
|
+
* imports the user module, awaits the default export, and prints the
|
|
24
|
+
* result as a marked JSON line for the parent to parse. */
|
|
25
|
+
const WRAPPER = `
|
|
26
|
+
const fs = await import("node:fs");
|
|
27
|
+
const path = await import("node:path");
|
|
28
|
+
const inputRaw = fs.readFileSync(0, "utf8");
|
|
29
|
+
let ctx = {};
|
|
30
|
+
try { ctx = JSON.parse(inputRaw || "{}"); } catch { ctx = {}; }
|
|
31
|
+
const mod = await import(path.resolve("./script.ts") + "?t=" + Date.now());
|
|
32
|
+
const fn = mod.default;
|
|
33
|
+
if (typeof fn !== "function") {
|
|
34
|
+
console.error("sandbox script must export default a function");
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const out = await fn(ctx);
|
|
39
|
+
if (out !== undefined && out !== null) {
|
|
40
|
+
process.stdout.write("__SANDBOX_OUTPUT__:" + JSON.stringify(out) + "\\n");
|
|
41
|
+
}
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
console.error(msg);
|
|
45
|
+
if (err instanceof Error && err.stack) console.error(err.stack);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
export async function runInSandbox(input) {
|
|
50
|
+
const timeoutMs = input.timeoutMs ?? 30_000;
|
|
51
|
+
const dir = input.cwd ?? mkTempDir();
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
writeFileSync(join(dir, "script.ts"), input.code);
|
|
54
|
+
writeFileSync(join(dir, "__sandbox_main.ts"), WRAPPER);
|
|
55
|
+
let timedOut = false;
|
|
56
|
+
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
|
+
]);
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
const marker = stdout.lastIndexOf("__SANDBOX_OUTPUT__:");
|
|
77
|
+
let output = null;
|
|
78
|
+
let cleanStdout = stdout;
|
|
79
|
+
if (marker !== -1) {
|
|
80
|
+
const line = stdout.slice(marker + "__SANDBOX_OUTPUT__:".length).trim();
|
|
81
|
+
cleanStdout = stdout.slice(0, marker).trimEnd();
|
|
82
|
+
try {
|
|
83
|
+
output = JSON.parse(line);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
output = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (timedOut) {
|
|
90
|
+
throw new SandboxTimeoutError(timeoutMs);
|
|
91
|
+
}
|
|
92
|
+
return { output, stdout: cleanStdout, stderr, exitCode, timedOut };
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (!input.keepCwd && !input.cwd) {
|
|
96
|
+
rmSync(dir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export class SandboxTimeoutError extends Error {
|
|
101
|
+
constructor(timeoutMs) {
|
|
102
|
+
super(`sandbox script timed out after ${timeoutMs}ms`);
|
|
103
|
+
this.name = "SandboxTimeoutError";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function mkTempDir() {
|
|
107
|
+
return resolve(tmpdir(), `sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`);
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { runInSandbox } from "./index.js";
|
|
3
|
+
test("runs a script and returns its output", async () => {
|
|
4
|
+
const r = await runInSandbox({
|
|
5
|
+
code: `export default async (ctx) => ({ echo: ctx.msg, doubled: ctx.n * 2 });`,
|
|
6
|
+
input: { msg: "hi", n: 21 },
|
|
7
|
+
timeoutMs: 15_000,
|
|
8
|
+
});
|
|
9
|
+
expect(r.exitCode).toBe(0);
|
|
10
|
+
expect(r.output).toEqual({ echo: "hi", doubled: 42 });
|
|
11
|
+
});
|
|
12
|
+
test("captures stdout and stderr", async () => {
|
|
13
|
+
const r = await runInSandbox({
|
|
14
|
+
code: `export default async () => { console.log("out-line"); console.error("err-line"); return { ok: true }; };`,
|
|
15
|
+
input: {},
|
|
16
|
+
});
|
|
17
|
+
expect(r.stdout).toContain("out-line");
|
|
18
|
+
expect(r.stderr).toContain("err-line");
|
|
19
|
+
expect(r.output).toEqual({ ok: true });
|
|
20
|
+
});
|
|
21
|
+
test("script error surfaces on stderr with non-zero exit", async () => {
|
|
22
|
+
const r = await runInSandbox({
|
|
23
|
+
code: `export default async () => { throw new Error("boom"); };`,
|
|
24
|
+
input: {},
|
|
25
|
+
});
|
|
26
|
+
expect(r.exitCode).not.toBe(0);
|
|
27
|
+
expect(r.stderr).toContain("boom");
|
|
28
|
+
expect(r.output).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
test("timeout kills the process", async () => {
|
|
31
|
+
await expect(runInSandbox({
|
|
32
|
+
code: `export default async () => { await new Promise(() => {}); };`,
|
|
33
|
+
input: {},
|
|
34
|
+
timeoutMs: 1_000,
|
|
35
|
+
})).rejects.toThrow(/timed out/);
|
|
36
|
+
});
|
|
37
|
+
test("minimal env — no host env leakage by default", async () => {
|
|
38
|
+
const r = await runInSandbox({
|
|
39
|
+
code: `export default async () => ({ hasSecret: typeof process.env.SANDBOX_LEAK_TEST !== "undefined" });`,
|
|
40
|
+
input: {},
|
|
41
|
+
env: {},
|
|
42
|
+
});
|
|
43
|
+
expect(r.output).toEqual({ hasSecret: false });
|
|
44
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chengchenccc/sandbox",
|
|
3
|
+
"version": "0.1.1-rc.1",
|
|
4
|
+
"description": "Process-isolated sandbox for executing untrusted workflow/eval scripts (Bun subprocess, timeout kill, JSON stdio)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.json",
|
|
13
|
+
"lint": "biome check . && eslint .",
|
|
14
|
+
"test": "bun test --pass-with-no-tests",
|
|
15
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/Chengchcc/my-agent-team.git",
|
|
21
|
+
"directory": "packages/sandbox"
|
|
22
|
+
}
|
|
23
|
+
}
|