@workerdeck/sandbox 0.6.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tobias Strebitzer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @workerdeck/sandbox
2
+
3
+ Execution sandbox for untrusted, LLM-generated scripts: a QuickJS-NG guest compiled to
4
+ WebAssembly, an in-memory scratch filesystem, and a hardened by-value host bridge. Deny-by-default
5
+ — the guest has no filesystem, network, timers, or host access except the capabilities you grant.
6
+
7
+ Part of [WorkerDeck](https://github.com/workerdeck/workerdeck). Leaf package: it
8
+ depends on neither `core`/`server` nor any model SDK, so the same guest engine runs server-side
9
+ (Node) and in a browser tab.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @workerdeck/sandbox @jitl/quickjs-ng-wasmfile-release-asyncify
15
+ ```
16
+
17
+ The WASM engine variant is injected rather than bundled, so you pick the build that fits your
18
+ target: `@jitl/quickjs-ng-wasmfile-release-asyncify` on the server,
19
+ `@jitl/quickjs-singlefile-browser-release-asyncify` in the browser (no separate `.wasm` fetch).
20
+ Use an **asyncify** variant — it lets guest code `await` a host function.
21
+
22
+ ## Usage
23
+
24
+ ```ts
25
+ import variant from '@jitl/quickjs-ng-wasmfile-release-asyncify'
26
+ import { createVfs, loadEngine, runScript } from '@workerdeck/sandbox'
27
+
28
+ // Load once and reuse; the module is stateless.
29
+ const engine = await loadEngine(variant)
30
+
31
+ const vfs = createVfs({ '/docs/report.txt': 'revenue: 12' })
32
+
33
+ const result = await runScript(engine, {
34
+ script: `
35
+ const doc = vfs.read('/docs/report.txt')
36
+ const revenue = Number(doc.split(':')[1])
37
+ vfs.write('/out/score.json', JSON.stringify({ revenue }))
38
+ revenue > 10 ? 'qualified' : 'skip'
39
+ `,
40
+ vfs,
41
+ memoryLimitBytes: 64 * 1024 * 1024,
42
+ timeoutMs: 5000,
43
+ })
44
+
45
+ if (result.ok) console.log(result.value, vfs.snapshot())
46
+ else console.error(result.reason, result.error)
47
+ ```
48
+
49
+ `runScript` never throws for guest misbehavior: exceptions, timeouts, and out-of-memory all come
50
+ back as `{ ok: false, reason }` so an agent loop can adapt to them.
51
+
52
+ ## What the guest can reach
53
+
54
+ Everything is granted explicitly; there is no ambient authority.
55
+
56
+ | Guest global | Backed by | Notes |
57
+ | --- | --- | --- |
58
+ | `console.log/warn/error` | captured | returned as `result.logs`, never written to the host console |
59
+ | `vfs.read/write/list` | the `vfs` option | absent-file reads return `undefined`; omit the option and writes throw |
60
+ | `fetchText(url)` | the `fetchText` option | omit it and the guest throws; **you** own the allowlist |
61
+
62
+ Absent by construction: `process`, `require`, `fetch`, `XMLHttpRequest`, `setTimeout`,
63
+ `setInterval`, `WebAssembly`, and any module loader.
64
+
65
+ ## Limits
66
+
67
+ `memoryLimitBytes` (default 64 MiB) caps the QuickJS allocator, and `timeoutMs` (default 5000) is
68
+ enforced by an interrupt handler that runs between bytecode operations — so an infinite loop is
69
+ preempted in-thread, with no worker and no cross-origin isolation. Each call gets a fresh runtime
70
+ and context; nothing carries over between runs.
71
+
72
+ **The deadline does not cover time spent inside your host functions.** The interpreter is not
73
+ executing while a host call is in flight, so put an independent timeout on every capability you
74
+ grant — especially `fetchText`.
75
+
76
+ ## Security model
77
+
78
+ The WebAssembly boundary is the easy part; the host bridge is the attack surface. Values cross it
79
+ **by value only** (strings and JSON), and a host object is never handed to the guest by reference.
80
+ That rule is what the guest-realm escape test exercises: walking `({}).constructor.constructor`
81
+ to `globalThis` succeeds, and lands in the guest's own realm with nothing of the host's in it.
82
+ This is the failure mode behind CVE-2026-5752, where a mock object's prototype chain leaked a
83
+ path to the host's `require()`.
84
+
85
+ If you extend the bridge, keep to it: marshal by value, freeze or null-prototype anything you
86
+ construct for the guest, and give every capability its own timeout.
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,81 @@
1
+ import { QuickJSAsyncVariant, QuickJSAsyncWASMModule } from "quickjs-emscripten-core";
2
+
3
+ //#region src/vfs.d.ts
4
+ /**
5
+ * Per-call in-memory scratch filesystem. Seeded from the task's documents,
6
+ * discarded after the call — never backed by host paths. The guest only ever
7
+ * touches it through the by-value host bridge (vfs_read/vfs_write/vfs_list).
8
+ *
9
+ * A plain path→content map on purpose: this package must run unpolyfilled in
10
+ * the browser (the tab-side tool host seeds a VFS per bridged call), and a
11
+ * node-flavored fs emulation drags `node:buffer` in with it.
12
+ */
13
+ type SandboxVfs = {
14
+ read(path: string): string | undefined;
15
+ write(path: string, content: string): void; /** File paths under `dir` (recursive), sorted. */
16
+ list(dir?: string): string[]; /** Full path → content map (e.g. to collect results after a run). */
17
+ snapshot(): Record<string, string>;
18
+ };
19
+ /** Collapse '.', '..' and empty segments into a rooted absolute path — the VFS
20
+ * has no host backing to escape into, this is pure path hygiene. */
21
+ declare function normalizeVfsPath(path: string): string;
22
+ declare function createVfs(seed?: Record<string, string>): SandboxVfs;
23
+ //#endregion
24
+ //#region src/run-script.d.ts
25
+ /**
26
+ * The guest engine, loaded from an injected WASM variant so server (Node
27
+ * asyncify) and browser (singlefile asyncify) share this package unchanged.
28
+ * Load once and reuse — the module is stateless; runtimes/contexts are per call.
29
+ */
30
+ type SandboxEngine = {
31
+ module: QuickJSAsyncWASMModule;
32
+ };
33
+ /** Accepts the variant itself, a `{ default }` module namespace, or a promise of
34
+ * either — so callers can pass `import('...')` or a plain default import without
35
+ * caring how their bundler/runtime resolved the interop. */
36
+ type SandboxVariantInput = QuickJSAsyncVariant | {
37
+ default: QuickJSAsyncVariant;
38
+ } | Promise<QuickJSAsyncVariant | {
39
+ default: QuickJSAsyncVariant;
40
+ }>;
41
+ declare function loadEngine(variant: SandboxVariantInput): Promise<SandboxEngine>;
42
+ type SandboxLog = {
43
+ level: 'log' | 'warn' | 'error';
44
+ text: string;
45
+ };
46
+ type RunScriptOptions = {
47
+ /** Untrusted, typically LLM-generated source. Evaluated as global code. */script: string; /** Scratch filesystem exposed to the guest as `vfs.read/write/list`. */
48
+ vfs?: SandboxVfs;
49
+ /**
50
+ * Host-gated network capability, exposed to the guest as `fetchText(url)`.
51
+ * The CALLER owns the allowlist, credential injection, and a per-call
52
+ * timeout — the guest never holds a credential and this module never
53
+ * touches the network itself. Unset = `fetchText` throws in the guest.
54
+ */
55
+ fetchText?: (url: string) => Promise<string>; /** QuickJS allocator cap. Guest OOM is a failed result, not a host crash. Default 64 MiB. */
56
+ memoryLimitBytes?: number;
57
+ /** Wall-clock deadline enforced by the interrupt handler between bytecode ops.
58
+ * Does NOT cover time inside host functions — bound those caller-side. Default 5000. */
59
+ timeoutMs?: number; /** Guest stack cap. Default 1 MiB. */
60
+ maxStackSizeBytes?: number;
61
+ signal?: AbortSignal;
62
+ };
63
+ type RunScriptResult = {
64
+ ok: true;
65
+ value: unknown;
66
+ logs: SandboxLog[];
67
+ } | {
68
+ ok: false;
69
+ reason: 'exception' | 'timeout' | 'oom' | 'aborted';
70
+ error: string;
71
+ logs: SandboxLog[];
72
+ };
73
+ /**
74
+ * Evaluate one untrusted script in a fresh QuickJS context: deny-by-default
75
+ * (no ambient fs/network/host access — only the granted bridge), interpreter-
76
+ * enforced memory and time limits, everything disposed afterwards.
77
+ */
78
+ declare function runScript(engine: SandboxEngine, options: RunScriptOptions): Promise<RunScriptResult>;
79
+ //#endregion
80
+ export { type RunScriptOptions, type RunScriptResult, type SandboxEngine, type SandboxLog, type SandboxVariantInput, type SandboxVfs, createVfs, loadEngine, normalizeVfsPath, runScript };
81
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1,203 @@
1
+ import { newQuickJSAsyncWASMModuleFromVariant } from "quickjs-emscripten-core";
2
+ //#region src/vfs.ts
3
+ /** Collapse '.', '..' and empty segments into a rooted absolute path — the VFS
4
+ * has no host backing to escape into, this is pure path hygiene. */
5
+ function normalizeVfsPath(path) {
6
+ const out = [];
7
+ for (const part of path.split("/")) {
8
+ if (part === "" || part === ".") continue;
9
+ if (part === "..") {
10
+ out.pop();
11
+ continue;
12
+ }
13
+ out.push(part);
14
+ }
15
+ return "/" + out.join("/");
16
+ }
17
+ function createVfs(seed) {
18
+ const files = /* @__PURE__ */ new Map();
19
+ const write = (path, content) => {
20
+ files.set(normalizeVfsPath(path), content);
21
+ };
22
+ for (const [path, content] of Object.entries(seed ?? {})) write(path, content);
23
+ return {
24
+ read(path) {
25
+ return files.get(normalizeVfsPath(path));
26
+ },
27
+ write,
28
+ list(dir = "/") {
29
+ const prefix = normalizeVfsPath(dir);
30
+ return [...files.keys()].filter((file) => prefix === "/" || file === prefix || file.startsWith(prefix + "/")).sort();
31
+ },
32
+ snapshot() {
33
+ return Object.fromEntries(files);
34
+ }
35
+ };
36
+ }
37
+ //#endregion
38
+ //#region src/run-script.ts
39
+ async function loadEngine(variant) {
40
+ const resolved = await variant;
41
+ return { module: await newQuickJSAsyncWASMModuleFromVariant("default" in resolved ? resolved.default : resolved) };
42
+ }
43
+ /** Trusted prelude, evaluated before the untrusted script: ergonomic frozen
44
+ * wrappers over the raw host functions. Everything crossing the boundary is a
45
+ * string (by-value marshalling — never a host object reference). */
46
+ const PRELUDE = `
47
+ "use strict";
48
+ (() => {
49
+ const fmt = (v) => {
50
+ if (typeof v === 'string') return v
51
+ if (v === undefined) return 'undefined'
52
+ try { return JSON.stringify(v) } catch { return String(v) }
53
+ }
54
+ globalThis.console = Object.freeze({
55
+ log: (...a) => __host_log('log', a.map(fmt).join(' ')),
56
+ warn: (...a) => __host_log('warn', a.map(fmt).join(' ')),
57
+ error: (...a) => __host_log('error', a.map(fmt).join(' ')),
58
+ })
59
+ globalThis.vfs = Object.freeze({
60
+ read: (p) => __host_vfs_read(String(p)),
61
+ write: (p, c) => { __host_vfs_write(String(p), String(c)) },
62
+ list: (d) => JSON.parse(__host_vfs_list(d === undefined ? '/' : String(d))),
63
+ })
64
+ globalThis.fetchText = (u) => __host_fetch_text(String(u))
65
+ })();
66
+ `;
67
+ /**
68
+ * Evaluate one untrusted script in a fresh QuickJS context: deny-by-default
69
+ * (no ambient fs/network/host access — only the granted bridge), interpreter-
70
+ * enforced memory and time limits, everything disposed afterwards.
71
+ */
72
+ async function runScript(engine, options) {
73
+ const logs = [];
74
+ const deadline = Date.now() + (options.timeoutMs ?? 5e3);
75
+ let interruptedBy;
76
+ const runtime = engine.module.newRuntime();
77
+ runtime.setMemoryLimit(options.memoryLimitBytes ?? 64 * 1024 * 1024);
78
+ runtime.setMaxStackSize(options.maxStackSizeBytes ?? 1024 * 1024);
79
+ runtime.setInterruptHandler(() => {
80
+ if (options.signal?.aborted) {
81
+ interruptedBy = "aborted";
82
+ return true;
83
+ }
84
+ if (Date.now() > deadline) {
85
+ interruptedBy = "timeout";
86
+ return true;
87
+ }
88
+ return false;
89
+ });
90
+ const context = runtime.newContext();
91
+ const defineHostFn = (name, fn) => {
92
+ const handle = context.newFunction(name, (...args) => fn(...args) ?? context.undefined);
93
+ context.setProp(context.global, name, handle);
94
+ handle.dispose();
95
+ };
96
+ try {
97
+ defineHostFn("__host_log", (levelHandle, textHandle) => {
98
+ const level = context.getString(levelHandle);
99
+ logs.push({
100
+ level: level === "warn" || level === "error" ? level : "log",
101
+ text: context.getString(textHandle)
102
+ });
103
+ });
104
+ defineHostFn("__host_vfs_read", (pathHandle) => {
105
+ const content = options.vfs?.read(context.getString(pathHandle));
106
+ return content === void 0 ? void 0 : context.newString(content);
107
+ });
108
+ defineHostFn("__host_vfs_write", (pathHandle, contentHandle) => {
109
+ if (!options.vfs) throw new Error("vfs is not enabled for this execution");
110
+ options.vfs.write(context.getString(pathHandle), context.getString(contentHandle));
111
+ });
112
+ defineHostFn("__host_vfs_list", (dirHandle) => {
113
+ const files = options.vfs?.list(context.getString(dirHandle)) ?? [];
114
+ return context.newString(JSON.stringify(files));
115
+ });
116
+ {
117
+ const fetchText = options.fetchText;
118
+ const handle = context.newAsyncifiedFunction("__host_fetch_text", async (urlHandle) => {
119
+ const url = context.getString(urlHandle);
120
+ if (!fetchText) throw new Error("network access is not enabled for this execution");
121
+ return context.newString(await fetchText(url));
122
+ });
123
+ context.setProp(context.global, "__host_fetch_text", handle);
124
+ handle.dispose();
125
+ }
126
+ const prelude = await context.evalCodeAsync(PRELUDE, "prelude.js");
127
+ context.unwrapResult(prelude).dispose();
128
+ const evaluated = await context.evalCodeAsync(options.script, "script.js");
129
+ if (evaluated.error) {
130
+ const error = context.dump(evaluated.error);
131
+ evaluated.error.dispose();
132
+ return failure(error, interruptedBy, logs);
133
+ }
134
+ runtime.executePendingJobs();
135
+ const evaluatedValue = evaluated.value;
136
+ const state = context.getPromiseState(evaluatedValue);
137
+ if (state.type === "pending") {
138
+ const settledPromise = context.resolvePromise(evaluatedValue);
139
+ runtime.executePendingJobs();
140
+ const settled = await settledPromise;
141
+ evaluatedValue.dispose();
142
+ if (settled.error) {
143
+ const error = context.dump(settled.error);
144
+ settled.error.dispose();
145
+ return failure(error, interruptedBy, logs);
146
+ }
147
+ const value = context.dump(settled.value);
148
+ settled.value.dispose();
149
+ return {
150
+ ok: true,
151
+ value,
152
+ logs
153
+ };
154
+ }
155
+ if (state.type === "rejected") {
156
+ const error = context.dump(state.error);
157
+ evaluatedValue.dispose();
158
+ return failure(error, interruptedBy, logs);
159
+ }
160
+ const resultHandle = state.type === "fulfilled" && !state.notAPromise ? state.value : evaluatedValue;
161
+ const value = context.dump(resultHandle);
162
+ if (resultHandle !== evaluatedValue) resultHandle.dispose();
163
+ evaluatedValue.dispose();
164
+ return {
165
+ ok: true,
166
+ value,
167
+ logs
168
+ };
169
+ } catch (error) {
170
+ return failure(error instanceof Error ? error.message : String(error), interruptedBy, logs);
171
+ } finally {
172
+ context.dispose();
173
+ runtime.dispose();
174
+ }
175
+ }
176
+ function failure(error, interruptedBy, logs) {
177
+ const text = describeGuestError(error);
178
+ return {
179
+ ok: false,
180
+ reason: interruptedBy ?? (/out of memory/i.test(text) ? "oom" : "exception"),
181
+ error: text,
182
+ logs
183
+ };
184
+ }
185
+ function describeGuestError(error) {
186
+ if (typeof error === "string") return error;
187
+ if (error && typeof error === "object") {
188
+ const e = error;
189
+ const name = typeof e.name === "string" ? e.name : void 0;
190
+ const message = typeof e.message === "string" ? e.message : void 0;
191
+ if (name || message) return [name, message].filter(Boolean).join(": ");
192
+ try {
193
+ return JSON.stringify(error);
194
+ } catch {
195
+ return String(error);
196
+ }
197
+ }
198
+ return String(error);
199
+ }
200
+ //#endregion
201
+ export { createVfs, loadEngine, normalizeVfsPath, runScript };
202
+
203
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/vfs.ts","../src/run-script.ts"],"sourcesContent":["/**\n * Per-call in-memory scratch filesystem. Seeded from the task's documents,\n * discarded after the call — never backed by host paths. The guest only ever\n * touches it through the by-value host bridge (vfs_read/vfs_write/vfs_list).\n *\n * A plain path→content map on purpose: this package must run unpolyfilled in\n * the browser (the tab-side tool host seeds a VFS per bridged call), and a\n * node-flavored fs emulation drags `node:buffer` in with it.\n */\nexport type SandboxVfs = {\n read(path: string): string | undefined\n write(path: string, content: string): void\n /** File paths under `dir` (recursive), sorted. */\n list(dir?: string): string[]\n /** Full path → content map (e.g. to collect results after a run). */\n snapshot(): Record<string, string>\n}\n\n/** Collapse '.', '..' and empty segments into a rooted absolute path — the VFS\n * has no host backing to escape into, this is pure path hygiene. */\nexport function normalizeVfsPath(path: string): string {\n const out: string[] = []\n for (const part of path.split('/')) {\n if (part === '' || part === '.') continue\n if (part === '..') {\n out.pop()\n continue\n }\n out.push(part)\n }\n return '/' + out.join('/')\n}\n\nexport function createVfs(seed?: Record<string, string>): SandboxVfs {\n const files = new Map<string, string>()\n const write = (path: string, content: string): void => {\n files.set(normalizeVfsPath(path), content)\n }\n for (const [path, content] of Object.entries(seed ?? {})) write(path, content)\n return {\n read(path) {\n return files.get(normalizeVfsPath(path))\n },\n write,\n list(dir = '/') {\n const prefix = normalizeVfsPath(dir)\n return [...files.keys()]\n .filter((file) => prefix === '/' || file === prefix || file.startsWith(prefix + '/'))\n .sort()\n },\n snapshot() {\n return Object.fromEntries(files)\n },\n }\n}\n","import {\n newQuickJSAsyncWASMModuleFromVariant,\n type QuickJSAsyncVariant,\n type QuickJSAsyncWASMModule,\n type QuickJSHandle,\n} from 'quickjs-emscripten-core'\nimport type { SandboxVfs } from './vfs.ts'\n\n/**\n * The guest engine, loaded from an injected WASM variant so server (Node\n * asyncify) and browser (singlefile asyncify) share this package unchanged.\n * Load once and reuse — the module is stateless; runtimes/contexts are per call.\n */\nexport type SandboxEngine = { module: QuickJSAsyncWASMModule }\n\n/** Accepts the variant itself, a `{ default }` module namespace, or a promise of\n * either — so callers can pass `import('...')` or a plain default import without\n * caring how their bundler/runtime resolved the interop. */\nexport type SandboxVariantInput =\n | QuickJSAsyncVariant\n | { default: QuickJSAsyncVariant }\n | Promise<QuickJSAsyncVariant | { default: QuickJSAsyncVariant }>\n\nexport async function loadEngine(variant: SandboxVariantInput): Promise<SandboxEngine> {\n const resolved = await variant\n const unwrapped = 'default' in resolved ? resolved.default : resolved\n return { module: await newQuickJSAsyncWASMModuleFromVariant(unwrapped) }\n}\n\nexport type SandboxLog = { level: 'log' | 'warn' | 'error'; text: string }\n\nexport type RunScriptOptions = {\n /** Untrusted, typically LLM-generated source. Evaluated as global code. */\n script: string\n /** Scratch filesystem exposed to the guest as `vfs.read/write/list`. */\n vfs?: SandboxVfs\n /**\n * Host-gated network capability, exposed to the guest as `fetchText(url)`.\n * The CALLER owns the allowlist, credential injection, and a per-call\n * timeout — the guest never holds a credential and this module never\n * touches the network itself. Unset = `fetchText` throws in the guest.\n */\n fetchText?: (url: string) => Promise<string>\n /** QuickJS allocator cap. Guest OOM is a failed result, not a host crash. Default 64 MiB. */\n memoryLimitBytes?: number\n /** Wall-clock deadline enforced by the interrupt handler between bytecode ops.\n * Does NOT cover time inside host functions — bound those caller-side. Default 5000. */\n timeoutMs?: number\n /** Guest stack cap. Default 1 MiB. */\n maxStackSizeBytes?: number\n signal?: AbortSignal\n}\n\nexport type RunScriptResult =\n | { ok: true; value: unknown; logs: SandboxLog[] }\n | {\n ok: false\n reason: 'exception' | 'timeout' | 'oom' | 'aborted'\n error: string\n logs: SandboxLog[]\n }\n\n/** Trusted prelude, evaluated before the untrusted script: ergonomic frozen\n * wrappers over the raw host functions. Everything crossing the boundary is a\n * string (by-value marshalling — never a host object reference). */\nconst PRELUDE = `\n\"use strict\";\n(() => {\n const fmt = (v) => {\n if (typeof v === 'string') return v\n if (v === undefined) return 'undefined'\n try { return JSON.stringify(v) } catch { return String(v) }\n }\n globalThis.console = Object.freeze({\n log: (...a) => __host_log('log', a.map(fmt).join(' ')),\n warn: (...a) => __host_log('warn', a.map(fmt).join(' ')),\n error: (...a) => __host_log('error', a.map(fmt).join(' ')),\n })\n globalThis.vfs = Object.freeze({\n read: (p) => __host_vfs_read(String(p)),\n write: (p, c) => { __host_vfs_write(String(p), String(c)) },\n list: (d) => JSON.parse(__host_vfs_list(d === undefined ? '/' : String(d))),\n })\n globalThis.fetchText = (u) => __host_fetch_text(String(u))\n})();\n`\n\n/**\n * Evaluate one untrusted script in a fresh QuickJS context: deny-by-default\n * (no ambient fs/network/host access — only the granted bridge), interpreter-\n * enforced memory and time limits, everything disposed afterwards.\n */\nexport async function runScript(engine: SandboxEngine, options: RunScriptOptions): Promise<RunScriptResult> {\n const logs: SandboxLog[] = []\n const deadline = Date.now() + (options.timeoutMs ?? 5000)\n let interruptedBy: 'timeout' | 'aborted' | undefined\n\n const runtime = engine.module.newRuntime()\n runtime.setMemoryLimit(options.memoryLimitBytes ?? 64 * 1024 * 1024)\n runtime.setMaxStackSize(options.maxStackSizeBytes ?? 1024 * 1024)\n runtime.setInterruptHandler(() => {\n if (options.signal?.aborted) {\n interruptedBy = 'aborted'\n return true\n }\n if (Date.now() > deadline) {\n interruptedBy = 'timeout'\n return true\n }\n return false\n })\n const context = runtime.newContext()\n\n const defineHostFn = (name: string, fn: (...args: QuickJSHandle[]) => QuickJSHandle | undefined): void => {\n const handle = context.newFunction(name, (...args) => fn(...args) ?? context.undefined)\n context.setProp(context.global, name, handle)\n handle.dispose()\n }\n\n try {\n defineHostFn('__host_log', (levelHandle, textHandle) => {\n const level = context.getString(levelHandle)\n logs.push({\n level: level === 'warn' || level === 'error' ? level : 'log',\n text: context.getString(textHandle),\n })\n return undefined\n })\n defineHostFn('__host_vfs_read', (pathHandle) => {\n const content = options.vfs?.read(context.getString(pathHandle))\n return content === undefined ? undefined : context.newString(content)\n })\n defineHostFn('__host_vfs_write', (pathHandle, contentHandle) => {\n if (!options.vfs) throw new Error('vfs is not enabled for this execution')\n options.vfs.write(context.getString(pathHandle), context.getString(contentHandle))\n return undefined\n })\n defineHostFn('__host_vfs_list', (dirHandle) => {\n const files = options.vfs?.list(context.getString(dirHandle)) ?? []\n return context.newString(JSON.stringify(files))\n })\n {\n const fetchText = options.fetchText\n const handle = context.newAsyncifiedFunction('__host_fetch_text', async (urlHandle) => {\n const url = context.getString(urlHandle)\n if (!fetchText) throw new Error('network access is not enabled for this execution')\n return context.newString(await fetchText(url))\n })\n context.setProp(context.global, '__host_fetch_text', handle)\n handle.dispose()\n }\n\n const prelude = await context.evalCodeAsync(PRELUDE, 'prelude.js')\n context.unwrapResult(prelude).dispose()\n\n const evaluated = await context.evalCodeAsync(options.script, 'script.js')\n if (evaluated.error) {\n const error = context.dump(evaluated.error)\n evaluated.error.dispose()\n return failure(error, interruptedBy, logs)\n }\n // Drain the microtask queue so a returned promise can settle. resolvePromise()\n // takes ownership of the handle it is given — never dispose that one again.\n runtime.executePendingJobs()\n const evaluatedValue = evaluated.value\n const state = context.getPromiseState(evaluatedValue)\n if (state.type === 'pending') {\n const settledPromise = context.resolvePromise(evaluatedValue)\n runtime.executePendingJobs()\n const settled = await settledPromise\n evaluatedValue.dispose()\n if (settled.error) {\n const error = context.dump(settled.error)\n settled.error.dispose()\n return failure(error, interruptedBy, logs)\n }\n const value = context.dump(settled.value)\n settled.value.dispose()\n return { ok: true, value, logs }\n }\n if (state.type === 'rejected') {\n const error = context.dump(state.error)\n evaluatedValue.dispose()\n return failure(error, interruptedBy, logs)\n }\n const resultHandle = state.type === 'fulfilled' && !state.notAPromise ? state.value : evaluatedValue\n const value = context.dump(resultHandle)\n if (resultHandle !== evaluatedValue) resultHandle.dispose()\n evaluatedValue.dispose()\n return { ok: true, value, logs }\n } catch (error) {\n return failure(error instanceof Error ? error.message : String(error), interruptedBy, logs)\n } finally {\n context.dispose()\n runtime.dispose()\n }\n}\n\nfunction failure(\n error: unknown,\n interruptedBy: 'timeout' | 'aborted' | undefined,\n logs: SandboxLog[],\n): RunScriptResult {\n const text = describeGuestError(error)\n const reason = interruptedBy ?? (/out of memory/i.test(text) ? 'oom' : 'exception')\n return { ok: false, reason, error: text, logs }\n}\n\nfunction describeGuestError(error: unknown): string {\n if (typeof error === 'string') return error\n if (error && typeof error === 'object') {\n const e = error as { name?: unknown; message?: unknown }\n const name = typeof e.name === 'string' ? e.name : undefined\n const message = typeof e.message === 'string' ? e.message : undefined\n if (name || message) return [name, message].filter(Boolean).join(': ')\n try {\n return JSON.stringify(error)\n } catch {\n return String(error)\n }\n }\n return String(error)\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@workerdeck/sandbox",
3
+ "version": "0.6.0",
4
+ "type": "module",
5
+ "description": "The WorkerDeck execution sandbox: QuickJS-NG-in-WASM guest for untrusted, LLM-generated scripts with an in-memory scratch VFS, a hardened by-value host bridge (capability host functions only — no ambient fs/network), and interpreter-enforced memory + wall-clock limits. Engine-variant-injected so server (Node asyncify) and browser (singlefile asyncify) share one guest engine. Leaf package: depends on neither core/server nor any model SDK.",
6
+ "license": "MIT",
7
+ "main": "./build/index.mjs",
8
+ "types": "./build/index.d.mts",
9
+ "files": [
10
+ "build"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "@workerdeck/source": "./src/index.ts",
15
+ "types": "./build/index.d.mts",
16
+ "default": "./build/index.mjs"
17
+ }
18
+ },
19
+ "dependencies": {
20
+ "quickjs-emscripten-core": "^0.31.0"
21
+ },
22
+ "devDependencies": {
23
+ "@jitl/quickjs-ng-wasmfile-release-asyncify": "^0.31.0",
24
+ "@types/node": "^22.10.0",
25
+ "rimraf": "^6.1.3",
26
+ "tsdown": "^0.21.10",
27
+ "vitest": "^3.2.0"
28
+ },
29
+ "author": "Tobias Strebitzer",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/workerdeck/workerdeck.git",
33
+ "directory": "packages/sandbox"
34
+ },
35
+ "homepage": "https://workerdeck.github.io/workerdeck/",
36
+ "bugs": "https://github.com/workerdeck/workerdeck/issues",
37
+ "keywords": [
38
+ "claude",
39
+ "sandbox",
40
+ "quickjs",
41
+ "wasm",
42
+ "untrusted-code",
43
+ "agent"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "scripts": {
49
+ "clean": "rimraf build",
50
+ "build": "tsdown",
51
+ "typecheck": "tsgo -p tsconfig.json",
52
+ "test": "vitest run"
53
+ }
54
+ }