@johnhenry/andbox 0.0.2 → 0.0.4

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/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # andbox
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/%40johnhenry%2Fandbox.svg)](https://www.npmjs.com/package/@johnhenry/andbox)
4
+ [![CI](https://github.com/johnhenry/andbox/actions/workflows/ci.yml/badge.svg)](https://github.com/johnhenry/andbox/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/%40johnhenry%2Fandbox.svg)](LICENSE)
6
+
7
+ Full documentation: [opensource.johnhenry.me/andbox](https://opensource.johnhenry.me/andbox/)
8
+
3
9
  A separate-context JavaScript runtime with Worker isolation, RPC capabilities, import maps, and timeouts.
4
10
 
5
11
  andbox runs JavaScript in a dedicated Web Worker with a structured bridge back to the host. Code in that Worker can call host-provided "capabilities" via RPC, use import-mapped packages, and define virtual modules -- all with configurable rate limits, timeouts, and hard-kill semantics.
@@ -8,6 +14,17 @@ andbox runs JavaScript in a dedicated Web Worker with a structured bridge back t
8
14
 
9
15
  Zero dependencies. Uses only Web Workers and standard browser APIs.
10
16
 
17
+ ## Contents
18
+
19
+ - [Install](#install)
20
+ - [Quick Start](#quick-start)
21
+ - [Sandbox Modes](#sandbox-modes)
22
+ - [API](#api)
23
+ - [Execution model](#execution-model)
24
+ - [Security model](#security-model)
25
+ - [Family](#family)
26
+ - [License](#license)
27
+
11
28
  ## Install
12
29
 
13
30
  ```bash
@@ -68,11 +85,12 @@ await sandbox.dispose();
68
85
 
69
86
  ## Sandbox Modes
70
87
 
71
- andbox supports four execution modes:
88
+ andbox supports five execution modes:
72
89
 
73
90
  - **`worker`** (default) -- Runs in a dedicated Worker with an RPC bridge, import maps, virtual modules, and hard-kill timeout semantics. See [Security model](#security-model) for what this does and doesn't protect against.
91
+ - **`node-worker`** -- The `worker` mode on `node:worker_threads`. Selected automatically under Node when there is no global `Worker`; see [Node](#node).
74
92
  - **`inline`** -- Same-thread execution via AsyncFunction. Lighter weight, no Worker overhead, no isolation at all -- code runs with full access to the calling context. Only for code you already trust.
75
- - **`data-uri`** -- Dynamic `import()` via Blob URL. Module-level separation without a Worker. Supports globals injection.
93
+ - **`data-uri`** -- Dynamic `import()` via Blob URL (a `data:` URL under Node). Module-level separation without a Worker. Supports globals injection.
76
94
  - **`service-worker`** -- Not code execution at all: registers a Service Worker that serves an in-memory `path → content` map with real HTTP-shaped fetch/navigation semantics. For hosting a small virtual multi-file site (HTML/CSS/JS, arbitrary paths), not for running JS in isolation. See [andbox#14](https://github.com/johnhenry/andbox/issues/14) and [Security model](#security-model) -- this mode does **not** provide isolation by merely existing.
77
95
 
78
96
  ```js
@@ -94,6 +112,27 @@ const site = await createSandbox({
94
112
  // Only navigate into scope *after* this resolves -- see Security model.
95
113
  ```
96
114
 
115
+ ## Node
116
+
117
+ From 0.0.4 no shim is needed. Under Node (`engines >= 26`), `createSandbox()` just works:
118
+
119
+ ```js
120
+ import { createSandbox } from '@johnhenry/andbox';
121
+ const sandbox = await createSandbox({ capabilities: { now: () => Date.now() } });
122
+ await sandbox.evaluate('return await host.call("now")');
123
+ await sandbox.dispose(); // worker threads keep the process alive until disposed
124
+ ```
125
+
126
+ **Mode selection.** `mode: 'worker'` (the default) picks `node:worker_threads` when `typeof Worker === 'undefined'` and the runtime is Node; if a global `Worker` exists (browser, or a shim you installed) that is used as before. `mode: 'node-worker'` forces the Node implementation. `workerFactory: (source) => WorkerLike` overrides both; `createNodeWorkerFactory()` is exported for that purpose.
127
+
128
+ **How it works.** The same worker script the browser uses is passed to `new worker_threads.Worker(prelude + source, { eval: true })`. No blob URLs are created. The prelude maps `parentPort` to `self.postMessage`/`onmessage`/`close`, and makes the worker's virtual-module loader mint `data:` URLs (Node cannot `import()` a `blob:` URL). `node:worker_threads` is loaded with a dynamic `import()` so browser bundles never see a `node:` specifier. Timeouts, `AbortSignal`, and `dispose()` hard-kill the thread with `terminate()` and a fresh one is started on the next call.
129
+
130
+ **Not supported / differences.**
131
+
132
+ - `mode: 'service-worker'` needs a browser and rejects under Node.
133
+ - Virtual modules are `data:` modules, so they cannot use *relative* imports of each other; use import maps or `sandboxImport()`. `createVirtualModuleRegistry()` still works as a path table in Node, but its `blob:` URLs cannot be `import()`ed there.
134
+ - A worker thread is not a security boundary: it shares the process, and sandboxed code can reach `process` and `require`. Treat it like the browser Worker mode, which also is not a defense against hostile code (see [Security model](#security-model)); for untrusted code on a server add OS-level isolation.
135
+
97
136
  ## API
98
137
 
99
138
  ### `createSandbox(options?)`
@@ -266,7 +305,18 @@ Code runs inside a Web Worker created from a Blob URL. This gets you, for free,
266
305
 
267
306
  ## Security model
268
307
 
269
- andbox is **not** a boundary against code that is actively trying to escape it. If you're running code you don't fully trust, read this section -- every item below is a confirmed way sandboxed code can act outside what `capabilities`/`policy`/`createNetworkFetch` appear to allow:
308
+ andbox is **not** a boundary against code that is actively trying to escape it. If you're running code you don't fully trust, read this section before you rely on `capabilities`/`policy`/`createNetworkFetch` for anything.
309
+
310
+ **What andbox guarantees:**
311
+
312
+ - **No DOM access.** Worker-mode code executes in a real Worker global scope, which has no `document`, `window`, or other DOM references -- this is a platform property of Workers, not something andbox has to enforce itself.
313
+ - **No implicit host object references.** Only what you explicitly pass in (`capabilities`, `globals`, import map entries) is reachable from sandboxed code -- ordinary (non-adversarial) code cannot accidentally read or mutate host-side state it wasn't given a reference to.
314
+ - **Hard kill on timeout.** `evaluate()` calls that exceed `timeoutMs` `terminate()` the Worker outright and start a fresh one for the next call -- this is a real process-level kill, not a cooperative cancellation the running code could ignore. See "still yours" below for what a kill does *not* undo.
315
+ - **The capability gate cannot be walked around via the prototype chain.** `gateCapabilities()` builds the gated object with `Object.create(null)`, so `host.call('constructor', ...)` cannot resolve through `Object.prototype` to the real global `Object` constructor. Previously fixed; see [andbox#5](https://github.com/johnhenry/andbox/issues/5).
316
+ - **`createNetworkFetch()`'s allowlist is redirect-safe.** Requests are made with `redirect: 'manual'` and any redirect response is rejected outright, so an allowlisted host cannot silently redirect a caller to a non-allowlisted one. Previously fixed; see [andbox#6](https://github.com/johnhenry/andbox/issues/6).
317
+ - **`gateCapabilities()` enforces call/argument-size/concurrency caps per capability**, for cooperative callers that stay within the capabilities you actually granted.
318
+
319
+ **What is still yours:**
270
320
 
271
321
  - **Worker-global APIs are directly reachable, regardless of `capabilities`.** Sandboxed code executes in a real Worker global scope, so `fetch`, `WebSocket`, `Worker` (nested workers), `importScripts`, `indexedDB`, and `self.postMessage` are all callable directly -- omitting a `fetch` capability does not block network access. This is fundamental to how `Function`-based evaluation works and isn't fixable without a different execution strategy (e.g. a cross-origin iframe with a strict CSP, or a Realms/Compartments-based approach). See [andbox#10](https://github.com/johnhenry/andbox/issues/10).
272
322
  - **`sandboxImport()` will load and execute an arbitrary remote URL.** Any `http(s)://` specifier is passed straight to `import()` with no allowlist, independent of any network policy configured for capabilities. See [andbox#7](https://github.com/johnhenry/andbox/issues/7).
@@ -274,12 +324,22 @@ andbox is **not** a boundary against code that is actively trying to escape it.
274
324
  - **`mode: 'service-worker'` does not provide isolation by merely existing.** It's a hosting mechanism -- a real Service Worker, same-origin by default, serving your `files` map with real fetch/navigation interception. Content served through it can see and touch its own origin exactly like any other same-origin page can; nothing about registering a Service Worker sandboxes what runs inside the pages it serves. If you're hosting content you don't fully trust, point this mode at a genuinely separate origin from day one -- the same recommendation the `fetch`/`WebSocket`/`Worker` item above makes for `worker` mode (a cross-origin iframe with a strict CSP), not something bolted on after the fact. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
275
325
  - **The Service Worker does not control the very first navigation into its scope.** A page/iframe navigation into `scope` that happens *before* the registration has finished activating is a normal, unintercepted network request -- Service Workers never retroactively intercept a request that already went out. `createSandbox({ mode: 'service-worker' })`'s returned promise only resolves once the registration is active (its generated script also calls `clients.claim()` on activate, which helps *already-open* clients but not fresh navigations); the documented, load-bearing contract is: don't navigate anything into `scope` until that promise resolves. Do that and every request is intercepted from the first byte, because the registration already matches `scope` before the navigation request is made. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
276
326
 
277
- Fixed as of this audit (kept here for history -- see the linked issues for details):
327
+ If you need to run untrusted/adversarial code safely, andbox alone is not sufficient -- pair it with OS-level isolation (a separate process/container with its own network and filesystem restrictions) or use a purpose-built sandboxing runtime. Capability gating and rate limits here are for organizing and throttling code you already trust, not for containing code you don't.
278
328
 
279
- - ~~`gateCapabilities()`'s check could be bypassed via the prototype chain~~ (`host.call('constructor', ...)` resolved through `Object.prototype` to the real global `Object` constructor). Fixed by building the gated object with `Object.create(null)`. See [andbox#5](https://github.com/johnhenry/andbox/issues/5).
280
- - ~~`createNetworkFetch()`'s allowlist was not redirect-safe~~ (it checked the request hostname before the fetch, not the final response URL). Fixed by fetching with `redirect: 'manual'` and rejecting any redirect response outright. See [andbox#6](https://github.com/johnhenry/andbox/issues/6).
329
+ ## Family
281
330
 
282
- If you need to run untrusted/adversarial code safely, andbox alone is not sufficient -- pair it with OS-level isolation (a separate process/container with its own network and filesystem restrictions) or use a purpose-built sandboxing runtime. Capability gating and rate limits here are for organizing and throttling code you already trust, not for containing code you don't.
331
+ andbox isn't just a standalone sandbox runtime -- it's the sandboxing engine
332
+ that [aimatey](https://github.com/johnhenry/aimatey) middleware wires in for
333
+ code-based tool execution.
334
+
335
+ - **[`@johnhenry/aimatey-middleware-andbox`](https://github.com/johnhenry/aimatey-middleware-andbox)** --
336
+ wires andbox's `createSandbox()` factory (or a pre-built sandbox) into an
337
+ [aimatey](https://github.com/johnhenry/aimatey) bridge middleware, via
338
+ `toolsToCapabilities()` to convert the middleware's `tools`/`executeToolFn`
339
+ into andbox `capabilities`. This package's own [Security model](#security-model)
340
+ is the source of truth for what that capability gate does and does not
341
+ guarantee -- the middleware's Security model section points back here
342
+ rather than repeating it.
283
343
 
284
344
  ## License
285
345
 
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@johnhenry/andbox",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, and timeouts",
6
6
  "main": "./src/index.mjs",
7
+ "types": "./src/index.d.ts",
7
8
  "exports": {
8
- ".": "./src/index.mjs"
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "import": "./src/index.mjs"
12
+ }
9
13
  },
10
14
  "files": [
11
15
  "src",
@@ -36,6 +40,6 @@
36
40
  },
37
41
  "homepage": "https://opensource.johnhenry.me/andbox/",
38
42
  "engines": {
39
- "node": ">=24.0.0"
43
+ "node": ">=26.0.0"
40
44
  }
41
45
  }
package/src/index.d.ts CHANGED
@@ -305,8 +305,36 @@ export interface SandboxOptions {
305
305
  policy?: GatePolicy;
306
306
  /** Console output handler. Called when sandboxed code uses console.log/warn/error/etc. */
307
307
  onConsole?: (level: string, ...args: string[]) => void;
308
+ /**
309
+ * Worker mode selection. Omitted/`'worker'` uses a Web Worker, or
310
+ * `node:worker_threads` automatically when run under Node with no global
311
+ * `Worker`. `'node-worker'` forces the Node implementation.
312
+ */
313
+ mode?: 'worker' | 'node-worker';
314
+ /**
315
+ * Supply the Worker implementation: given the worker script source, return
316
+ * a Web-Worker-shaped object. Overrides automatic selection. See
317
+ * `createNodeWorkerFactory()`.
318
+ */
319
+ workerFactory?: (source: string) => WorkerLike;
308
320
  }
309
321
 
322
+ /** The subset of the Web Worker interface andbox drives. */
323
+ export interface WorkerLike {
324
+ onmessage: ((ev: { data: any }) => void) | null;
325
+ onerror: ((ev: { message?: string }) => void) | null;
326
+ postMessage(message: unknown): void;
327
+ addEventListener(type: 'message', fn: (ev: { data: any }) => void): void;
328
+ removeEventListener(type: 'message', fn: (ev: { data: any }) => void): void;
329
+ terminate(): void;
330
+ }
331
+
332
+ /**
333
+ * Node only: load `node:worker_threads` and return a synchronous factory that
334
+ * runs andbox's worker source in a thread. Suitable for `workerFactory`.
335
+ */
336
+ export declare function createNodeWorkerFactory(): Promise<(source: string) => WorkerLike>;
337
+
310
338
  /** Sandbox statistics. */
311
339
  export interface SandboxStats {
312
340
  disposed: boolean;
package/src/index.mjs CHANGED
@@ -15,3 +15,4 @@ export { DEFAULT_TIMEOUT_MS, DEFAULT_LIMITS, DEFAULT_CAPABILITY_LIMITS } from '.
15
15
  export { makeWorkerSource } from './worker-source.mjs';
16
16
  export { makeServiceWorkerSource } from './service-worker-source.mjs';
17
17
  export { resolveServiceWorkerResponse } from './service-worker-response.mjs';
18
+ export { createNodeWorkerFactory } from './node-worker.mjs';
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Node adapter: a Web-Worker-shaped object on top of `node:worker_threads`.
3
+ *
4
+ * The browser path builds a Blob URL and passes it to `new Worker(url)`.
5
+ * Node has no global `Worker`, `worker_threads` cannot start from a `blob:`
6
+ * URL, and the ESM loader cannot `import()` one. So in Node the worker
7
+ * source is passed as text with `{ eval: true }` (the thread starts as a
8
+ * CommonJS script, which is all the worker source needs), preceded by a
9
+ * small prelude that maps `parentPort` onto `self.postMessage` /
10
+ * `self.onmessage` / `self.close`, and shims `Blob` + `URL.createObjectURL`
11
+ * *inside the thread only* so the worker's virtual-module loader mints
12
+ * `data:` URLs (which Node can `import()`) instead of `blob:` URLs. The
13
+ * worker source itself is the same string the browser uses.
14
+ *
15
+ * `node:worker_threads` is loaded with a dynamic `import()` so the browser
16
+ * entry never pulls a `node:` specifier into a bundle's static graph.
17
+ */
18
+
19
+ const PRELUDE = `
20
+ const { parentPort } = require('node:worker_threads');
21
+ globalThis.self = globalThis;
22
+ globalThis.postMessage = (m) => parentPort.postMessage(m);
23
+ globalThis.close = () => process.exit(0);
24
+ parentPort.on('message', (data) => {
25
+ try { const r = globalThis.onmessage?.({ data }); r?.catch?.((e) => { throw e; }); }
26
+ catch (e) { parentPort.postMessage({ type: 'error', message: String(e?.message ?? e) }); }
27
+ });
28
+ // import(blob:) is unsupported by Node's ESM loader; data: works.
29
+ globalThis.Blob = class AndboxNodeBlob {
30
+ constructor(parts) { this.text = parts.map(String).join(''); }
31
+ };
32
+ URL.createObjectURL = (b) => 'data:text/javascript;base64,' + Buffer.from(b.text).toString('base64');
33
+ URL.revokeObjectURL = () => {};
34
+ `;
35
+
36
+ class NodeWebWorker {
37
+ #thread;
38
+ #listeners = new Set();
39
+ #terminated = false;
40
+ onmessage = null;
41
+ onerror = null;
42
+
43
+ constructor(ThreadWorker, source) {
44
+ const w = new ThreadWorker(PRELUDE + '\n' + source, { eval: true });
45
+ w.on('message', (data) => {
46
+ const ev = { data };
47
+ this.onmessage?.(ev);
48
+ for (const fn of [...this.#listeners]) fn(ev);
49
+ });
50
+ w.on('error', (e) => this.onerror?.({ message: e?.message ?? String(e) }));
51
+ w.on('exit', (code) => {
52
+ if (!this.#terminated && code !== 0) {
53
+ this.onerror?.({ message: `worker exited with code ${code}` });
54
+ }
55
+ });
56
+ this.#thread = w;
57
+ }
58
+
59
+ postMessage(m) {
60
+ if (!this.#terminated) this.#thread.postMessage(m);
61
+ }
62
+ addEventListener(type, fn) { if (type === 'message') this.#listeners.add(fn); }
63
+ removeEventListener(type, fn) { if (type === 'message') this.#listeners.delete(fn); }
64
+ terminate() {
65
+ this.#terminated = true;
66
+ this.#listeners.clear();
67
+ this.#thread.terminate();
68
+ }
69
+ }
70
+
71
+ /** True when running under Node (including with no global `Worker`). */
72
+ export function isNodeRuntime() {
73
+ return typeof process !== 'undefined' && typeof process?.versions?.node === 'string';
74
+ }
75
+
76
+ /**
77
+ * Load `node:worker_threads` and return a synchronous factory
78
+ * `(workerSource: string) => Worker-like` (onmessage/onerror/postMessage/
79
+ * addEventListener/removeEventListener/terminate). Pass it as the
80
+ * `workerFactory` option of `createSandbox()` to force it explicitly.
81
+ *
82
+ * @returns {Promise<(source: string) => object>}
83
+ */
84
+ export async function createNodeWorkerFactory() {
85
+ if (!isNodeRuntime()) {
86
+ throw new Error("createNodeWorkerFactory() requires Node (node:worker_threads); in a browser use mode: 'worker'.");
87
+ }
88
+ const { Worker: ThreadWorker } = await import('node:worker_threads');
89
+ return (source) => new NodeWebWorker(ThreadWorker, source);
90
+ }
package/src/sandbox.mjs CHANGED
@@ -14,6 +14,7 @@ import { makeWorkerSource } from './worker-source.mjs';
14
14
  import { gateCapabilities } from './capability-gate.mjs';
15
15
  import { makeDeferred, makeTimeoutError, makeAbortError } from './deferred.mjs';
16
16
  import { DEFAULT_TIMEOUT_MS } from './constants.mjs';
17
+ import { isNodeRuntime, createNodeWorkerFactory } from './node-worker.mjs';
17
18
 
18
19
  const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
19
20
 
@@ -62,7 +63,7 @@ function createDataUriSandbox(opts = {}) {
62
63
  const output = [];
63
64
  const globalEntries = Object.entries(globals);
64
65
  const preamble = globalEntries.length > 0
65
- ? `const { ${globalEntries.map(([k]) => k).join(', ')} } = globalThis.__andbox_globals__;\n`
66
+ ? `const { ${globalEntries.map(([k]) => k).join(', ')} } = __globals__;\n`
66
67
  : '';
67
68
  const wrappedCode = `
68
69
  const __globals__ = globalThis.__andbox_globals__;
@@ -70,8 +71,11 @@ const print = globalThis.__andbox_print__;
70
71
  delete globalThis.__andbox_globals__;
71
72
  delete globalThis.__andbox_print__;
72
73
  ${preamble}${code}`;
73
- const blob = new Blob([wrappedCode], { type: 'text/javascript' });
74
- const url = URL.createObjectURL(blob);
74
+ // Node's ESM loader cannot import() a blob: URL; it can a data: URL.
75
+ const nodeRuntime = isNodeRuntime();
76
+ const url = nodeRuntime
77
+ ? 'data:text/javascript;base64,' + Buffer.from(wrappedCode).toString('base64')
78
+ : URL.createObjectURL(new Blob([wrappedCode], { type: 'text/javascript' }));
75
79
  const print = (...args) => {
76
80
  output.push(args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '));
77
81
  };
@@ -92,7 +96,7 @@ ${preamble}${code}`;
92
96
  return { success: false, output: output.join('\n'), error: e.message || String(e) };
93
97
  } finally {
94
98
  clearTimeout(timer);
95
- URL.revokeObjectURL(url);
99
+ if (!nodeRuntime) URL.revokeObjectURL(url);
96
100
  delete globalThis.__andbox_globals__;
97
101
  delete globalThis.__andbox_print__;
98
102
  }
@@ -314,10 +318,10 @@ export function createSandbox(options = {}) {
314
318
  if (mode === 'inline') return createInlineSandbox(options);
315
319
  if (mode === 'data-uri') return createDataUriSandbox(options);
316
320
  if (mode === 'service-worker') return createServiceWorkerSandbox(options);
317
- return createWorkerSandbox(options);
321
+ return createWorkerSandbox(options, mode === 'node-worker');
318
322
  }
319
323
 
320
- async function createWorkerSandbox(options = {}) {
324
+ async function createWorkerSandbox(options = {}, forceNode = false) {
321
325
  const {
322
326
  importMap = { imports: {}, scopes: {} },
323
327
  capabilities = {},
@@ -327,6 +331,14 @@ async function createWorkerSandbox(options = {}) {
327
331
  onConsole,
328
332
  } = options;
329
333
 
334
+ // Node mode: no global Worker (and no blob: worker URLs) -> node:worker_threads.
335
+ // An explicit workerFactory always wins; 'node-worker' forces Node; the
336
+ // default selects it only when there is no global Worker under Node.
337
+ let workerFactory = options.workerFactory || null;
338
+ if (!workerFactory && (forceNode || (typeof Worker === 'undefined' && isNodeRuntime()))) {
339
+ workerFactory = await createNodeWorkerFactory();
340
+ }
341
+
330
342
  // Gate capabilities with rate limits
331
343
  const { gated: gatedCaps, stats: gateStats } = gateCapabilities(capabilities, policy);
332
344
 
@@ -346,10 +358,18 @@ async function createWorkerSandbox(options = {}) {
346
358
 
347
359
  function createWorker() {
348
360
  const source = makeWorkerSource();
361
+ if (workerFactory) {
362
+ worker = workerFactory(source);
363
+ attachWorkerHandlers();
364
+ return;
365
+ }
349
366
  const blob = new Blob([source], { type: 'application/javascript' });
350
367
  workerBlobURL = URL.createObjectURL(blob);
351
368
  worker = new Worker(workerBlobURL, { type: 'classic' });
369
+ attachWorkerHandlers();
370
+ }
352
371
 
372
+ function attachWorkerHandlers() {
353
373
  worker.onmessage = ({ data: msg }) => {
354
374
  switch (msg.type) {
355
375
  case 'configured':