@johnhenry/andbox 0.0.5 → 0.0.7

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
@@ -85,7 +85,7 @@ await sandbox.dispose();
85
85
 
86
86
  ## Sandbox Modes
87
87
 
88
- andbox supports five execution modes:
88
+ andbox supports five execution modes (any other `mode` throws an error listing these):
89
89
 
90
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
91
  - **`node-worker`** -- The `worker` mode on `node:worker_threads`. Selected automatically under Node when there is no global `Worker`; see [Node](#node).
@@ -125,13 +125,42 @@ await sandbox.dispose(); // worker threads keep the process alive until disposed
125
125
 
126
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
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.
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 installs an in-thread module loader hook (`module.registerHooks`) that serves virtual modules from memory (see below). `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
+ **Virtual modules can import each other (0.0.6).** `defineModule()` modules are served under `andbox-vfs://` URLs, so they can import one another relatively (`./b`, `../lib/c.js`), by bare name (`import x from "lib/x"`), through the sandbox `importMap`, and cyclically. Extensions are optional (`./b`, `./b.js`, `./b/index.js` all find `b`). Redefining a module (or a dependency) takes effect on the next `sandboxImport()`. Difference from the browser: modules are cached per definition, so repeated `sandboxImport(name)` returns the same module instance until it is redefined. `createVirtualModuleRegistry()` uses the same mechanism under Node (see its section).
131
+
132
+ **Process lifetime.** A live worker thread keeps the host process alive until `dispose()` (confirmed by test: no `dispose()` and the process never exits; after `dispose()` it exits). Pass `unref: true` to let the process exit while the sandbox is *idle*: the thread is unref'd between calls and ref'd again while a startup, `evaluate()` or `defineModule()` is in flight, so an `await sandbox.evaluate(...)` is never abandoned. Default `false`. (Ignored with a browser Worker.)
133
+
134
+ **Hardening (opt-in): `nodeWorker`.** By default the thread behaves like any `worker_threads` thread: it gets a copy of `process.env`, no memory cap, and full access to `process`, `fs`, `child_process`, etc. These options tighten that:
135
+
136
+ ```js
137
+ const sandbox = await createSandbox({
138
+ nodeWorker: {
139
+ permissions: true, // see below
140
+ // env: { MODE: 'x' }, // explicit env; with permissions the default is {}
141
+ // maxMemoryMb: 128, // heap cap (default 256 with permissions: true)
142
+ // resourceLimits: {...}, // raw worker_threads resourceLimits, merged on top
143
+ // execArgv: [...], // extra thread flags, e.g. '--allow-fs-read=/data'
144
+ // captureStdio: true, // route thread stdout/stderr to onConsole('stdout'|'stderr', text)
145
+ },
146
+ });
147
+ ```
148
+
149
+ `permissions: true` does all of the following to the thread:
150
+
151
+ - **Permission model.** Spawns it with `execArgv: ['--permission']` (this works per-thread in Node 26 even though the flag is documented process-wide). Verified by test: `fs` reads and `child_process` are denied with `ERR_ACCESS_DENIED`, Nested workers and native addons are not granted (`--allow-worker` / `--allow-addons` are not passed), though only the `fs` and `child_process` denials are covered by tests. Grant specific paths with `execArgv: ['--allow-fs-read=/dir']`.
152
+ - **Isolated `env`.** `{}` (or the object you pass) instead of a copy of the host's environment, so secrets in `process.env` are not visible.
153
+ - **Memory cap.** `resourceLimits.maxOldGenerationSizeMb` (default 256, or `maxMemoryMb`). An allocation loop kills the thread with an out-of-memory error, the pending `evaluate()` rejects, and the next call starts a fresh thread. (andbox has no other memory-limit option, so `maxMemoryMb` is new here; `policy.limits` only governs capability calls.)
154
+ - **Stripped `process`.** Deletes `process.binding`, `_linkedBinding`, `getBuiltinModule`, `dlopen`, `kill`, `abort`, `reallyExit`, `mainModule`.
155
+ - **Blocked host imports.** A resolve hook rejects any `import()` / `sandboxImport()` that resolves to a `node:` or `file:` URL (`import('node:fs')`, `import('fs')`, `import('file:///...')`). `data:`, `http(s):` and virtual modules still work.
156
+ - **Captured stdio.** Thread `process.stdout/stderr` writes go to `onConsole` instead of the host terminal.
157
+
158
+ **What this does *not* prevent.** The thread is still in your process. `permissions` does not restrict the network (`fetch`, `WebSocket`, `net` via any route that remains), CPU use (use `timeoutMs`), or anything that exploits a bug in V8 or Node; the `process` stripping and import hook are denylists, not a proof, and a determined attacker may find another path to a host capability. It only reduces the blast radius of ordinary and semi-hostile code. See [Security model](#security-model). The options throw if combined with a browser Worker or a custom `workerFactory`, rather than silently doing nothing.
129
159
 
130
160
  **Not supported / differences.**
131
161
 
132
162
  - `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.
163
+ - **A worker thread is not a security boundary** -- not by default and not with `nodeWorker.permissions`. By default sandboxed code can reach `process` (including `process.env`, `process.binding`, `process.getBuiltinModule('fs')`) and can `import('node:child_process')`. For untrusted code on a server add OS-level isolation (a separate process or container with its own filesystem, network and resource limits).
135
164
 
136
165
  ## API
137
166
 
@@ -204,7 +233,7 @@ Resolves a module specifier against an import map, following the browser import
204
233
 
205
234
  ### `createVirtualModuleRegistry(files?, options?)`
206
235
 
207
- Takes a `path → source` map and mints one real `blob:` URL per entry (the same `new Blob([...]) + URL.createObjectURL()` pattern `data-uri` mode uses, generalized to a whole file table). Returns a registry for a multi-file tree that references itself by relative path -- an entry module `import`-ing `./util.js`, which itself imports `../shared/x.js`, and so on.
236
+ Takes a `path → source` map and mints one importable URL per entry: a real `blob:` URL in a browser (the same `new Blob([...]) + URL.createObjectURL()` pattern `data-uri` mode uses, generalized to a whole file table), or, under Node (which cannot `import()` a `blob:` URL), an `andbox-vfs://<id>/<path>?v=<n>` URL served by an in-thread module hook. `options.backend` is `'auto'` (default), `'blob'` or `'node'`. Returns a registry for a multi-file tree that references itself by relative path -- an entry module `import`-ing `./util.js`, which itself imports `../shared/x.js`, and so on.
208
237
 
209
238
  ```js
210
239
  import { createVirtualModuleRegistry } from '@johnhenry/andbox';
@@ -214,15 +243,18 @@ const registry = createVirtualModuleRegistry({
214
243
  'util.js': 'export function add(a, b) { return a + b; }',
215
244
  });
216
245
 
217
- registry.resolve('index.js'); // blob: URL, or null if unknown
246
+ registry.resolve('index.js'); // importable URL, or null if unknown
247
+ registry.source('index.js'); // the registered source text
218
248
  registry.resolveSpecifier('./util.js', 'index.js'); // resolves relative to the importing file
219
249
  registry.define('extra.js', 'export const x = 1;'); // register (or replace) a file at runtime
220
- registry.dispose(); // revokes every blob URL it ever minted
250
+ registry.dispose(); // revokes blob URLs / drops the Node files
221
251
  ```
222
252
 
223
253
  `resolveSpecifier(specifier, parentPath?)` checks, in order: (1) import-map resolution via `resolveWithImportMap()` -- not reimplemented, just delegated, with `parentPath`'s own blob URL passed through as `parentURL` so `scopes` apply; (2) a relative-path (`./`, `../`) fallback looked up against the registry's own file table, which `resolveWithImportMap()` alone has no notion of; (3) `null` if neither matched, e.g. a genuine external bare specifier -- left for the caller to handle, not swallowed.
224
254
 
225
- **Honest caveat:** this only solves module *resolution* (the URL a given specifier should point at), not automatic rewriting of `import` statements inside the source text, and not general asset URLs (`<img src>`, CSS `url()`) or navigation. A `blob:` URL has no hierarchical path of its own, so a literal `import "./util.js"` statement inside blob-served source will **not** resolve on its own in a browser -- call `resolveSpecifier()` yourself with the specifier your loader saw and use the URL it returns (or rewrite the specifier to that URL before creating the blob). See [andbox#13](https://github.com/johnhenry/andbox/issues/13) for the full design discussion, and [andbox#14](https://github.com/johnhenry/andbox/issues/14) for the harder "make it behave like a real server" problem this deliberately does not attempt to solve.
255
+ **Under Node, relative imports just work.** `await import(registry.resolve('index.js'))` runs `index.js`, whose `./util.js` import is resolved by the hook (relative, bare-name, import map, cycles, and extensionless/`index.js` lookups). Redefine a file with `define()` and import the new URL it returns.
256
+
257
+ **Honest caveat (browser / `blob:` backend):** this only solves module *resolution* (the URL a given specifier should point at), not automatic rewriting of `import` statements inside the source text, and not general asset URLs (`<img src>`, CSS `url()`) or navigation. A `blob:` URL has no hierarchical path of its own, so a literal `import "./util.js"` statement inside blob-served source will **not** resolve on its own in a browser -- call `resolveSpecifier()` yourself with the specifier your loader saw and use the URL it returns (or rewrite the specifier to that URL before creating the blob). See [andbox#13](https://github.com/johnhenry/andbox/issues/13) for the full design discussion, and [andbox#14](https://github.com/johnhenry/andbox/issues/14) for the harder "make it behave like a real server" problem this deliberately does not attempt to solve.
226
258
 
227
259
  ### `createNetworkFetch(allowedHosts?, fetchFn?)`
228
260
 
@@ -311,6 +343,7 @@ andbox is **not** a boundary against code that is actively trying to escape it.
311
343
 
312
344
  - **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
345
  - **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.
346
+ - **(Node) A worker thread is not a security boundary.** It shares the process with the host; by default it inherits `process.env` and can reach `process`, `fs`, and `child_process`. The opt-in `nodeWorker.permissions` hardening (see [Node](#node)) adds the permission model, an isolated env, a memory cap and import blocking, which raises the cost of casual abuse but does not make the thread a boundary.
314
347
  - **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
348
  - **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
349
  - **`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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/andbox",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, and timeouts",
6
6
  "main": "./src/index.mjs",
@@ -43,6 +43,8 @@
43
43
  "node": ">=26.0.0"
44
44
  },
45
45
  "devDependencies": {
46
- "esbuild": "^0.28.2"
46
+ "esbuild": "^0.28.2",
47
+ "vite": "^8.3.3",
48
+ "webpack": "^5.111.1"
47
49
  }
48
50
  }
package/src/index.d.ts CHANGED
@@ -135,7 +135,11 @@ export declare function resolveWithImportMap(
135
135
 
136
136
  // ── virtual-module-registry ──
137
137
 
138
- /** A registry of in-memory files, each backed by its own `blob:` URL. */
138
+ /**
139
+ * A registry of in-memory files: each backed by its own `blob:` URL in a
140
+ * browser, or an importable `andbox-vfs:` URL under Node (where files can
141
+ * import each other relatively).
142
+ */
139
143
  export interface VirtualModuleRegistry {
140
144
  /**
141
145
  * Get the `blob:` URL registered for a path.
@@ -165,6 +169,9 @@ export interface VirtualModuleRegistry {
165
169
  */
166
170
  define(path: string, source: string): string;
167
171
 
172
+ /** The source text registered for a path, or null. Works in every runtime. */
173
+ source(path: string): string | null;
174
+
168
175
  /** Whether a path is registered. */
169
176
  has(path: string): boolean;
170
177
 
@@ -188,7 +195,11 @@ export interface VirtualModuleRegistry {
188
195
  */
189
196
  export declare function createVirtualModuleRegistry(
190
197
  files?: Record<string, string>,
191
- options?: { importMap?: ImportMap },
198
+ options?: {
199
+ importMap?: ImportMap;
200
+ /** `'auto'` (default): Node hooks under Node, `blob:` elsewhere. */
201
+ backend?: 'auto' | 'blob' | 'node';
202
+ },
192
203
  ): VirtualModuleRegistry;
193
204
 
194
205
  // ── network-policy ──
@@ -311,12 +322,52 @@ export interface SandboxOptions {
311
322
  * `Worker`. `'node-worker'` forces the Node implementation.
312
323
  */
313
324
  mode?: 'worker' | 'node-worker';
325
+ // Any other value throws: supported modes are 'worker', 'node-worker', 'inline', 'data-uri', 'service-worker'.
314
326
  /**
315
327
  * Supply the Worker implementation: given the worker script source, return
316
328
  * a Web-Worker-shaped object. Overrides automatic selection. See
317
329
  * `createNodeWorkerFactory()`.
318
330
  */
319
331
  workerFactory?: (source: string) => WorkerLike;
332
+ /**
333
+ * Node worker_threads options and opt-in hardening. Only valid with the
334
+ * built-in Node mode (throws with a browser Worker or custom workerFactory).
335
+ * A worker thread is NOT a security boundary even with `permissions: true`.
336
+ */
337
+ nodeWorker?: NodeWorkerOptions;
338
+ /**
339
+ * Node only (default false). When true the thread is unref'd while the
340
+ * sandbox is idle so a forgotten sandbox does not keep the process alive;
341
+ * it stays ref'd during startup, evaluate() and defineModule(). Without it
342
+ * a live sandbox keeps the process alive until dispose().
343
+ */
344
+ unref?: boolean;
345
+ }
346
+
347
+ /** Options for the built-in Node worker_threads mode (`nodeWorker`). */
348
+ export interface NodeWorkerOptions {
349
+ /**
350
+ * Spawn the thread with Node's permission model (`--permission`), an
351
+ * isolated `env` (`{}` unless `env` is given), a default 256 MB heap cap,
352
+ * stripped `process` escapes, blocked `node:`/`file:` imports and captured
353
+ * stdio. Does not restrict network or CPU.
354
+ */
355
+ permissions?: boolean;
356
+ /** Environment for the thread. Default: a copy of `process.env` (or `{}` with `permissions`). */
357
+ env?: Record<string, string>;
358
+ /** Heap cap in megabytes (`resourceLimits.maxOldGenerationSizeMb`). */
359
+ maxMemoryMb?: number;
360
+ /** Raw `worker_threads` resourceLimits, merged over `maxMemoryMb`. */
361
+ resourceLimits?: {
362
+ maxOldGenerationSizeMb?: number;
363
+ maxYoungGenerationSizeMb?: number;
364
+ codeRangeSizeMb?: number;
365
+ stackSizeMb?: number;
366
+ };
367
+ /** Extra thread `execArgv`, e.g. `['--allow-fs-read=/data']`. */
368
+ execArgv?: string[];
369
+ /** Route thread stdout/stderr to `onConsole('stdout' | 'stderr', text)`. Default: true with `permissions`. */
370
+ captureStdio?: boolean;
320
371
  }
321
372
 
322
373
  /** The subset of the Web Worker interface andbox drives. */
@@ -333,7 +384,9 @@ export interface WorkerLike {
333
384
  * Node only: load `node:worker_threads` and return a synchronous factory that
334
385
  * runs andbox's worker source in a thread. Suitable for `workerFactory`.
335
386
  */
336
- export declare function createNodeWorkerFactory(): Promise<(source: string) => WorkerLike>;
387
+ export declare function createNodeWorkerFactory(
388
+ options?: NodeWorkerOptions,
389
+ ): Promise<(source: string) => WorkerLike>;
337
390
 
338
391
  /** Sandbox statistics. */
339
392
  export interface SandboxStats {
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Node virtual filesystem for ES modules.
3
+ *
4
+ * Node's ESM loader cannot `import()` a `blob:` URL, and a `data:` URL has
5
+ * no hierarchical base, so two virtual modules can never import each other
6
+ * relatively through either. This installs in-thread module customization
7
+ * hooks (`module.registerHooks`, synchronous, same thread) that serve an
8
+ * in-memory file table under a hierarchical custom scheme:
9
+ *
10
+ * andbox-vfs://<namespace>/<path>?v=<version>
11
+ *
12
+ * Relative specifiers (`./b.js`, `../c`) resolve against that base natively,
13
+ * bare specifiers are looked up as other virtual module names and then in a
14
+ * per-namespace import map, and cycles work as they do for real files.
15
+ *
16
+ * `installNodeVfs` is deliberately self-contained (no free variables) so the
17
+ * Node worker adapter can inline `installNodeVfs.toString()` into the thread
18
+ * prelude and share one implementation between the host and the thread.
19
+ *
20
+ * @param {(hooks: object) => unknown} registerHooks `module.registerHooks`
21
+ */
22
+ export function installNodeVfs(registerHooks) {
23
+ const KEY = Symbol.for('andbox.vfs');
24
+ if (globalThis[KEY]) return globalThis[KEY];
25
+
26
+ const SCHEME = 'andbox-vfs:';
27
+ const spaces = new Map(); // ns -> { files: Map(name -> {v, src}), hist: Map, importMap }
28
+ let counter = 0;
29
+ let denyHostResources = false;
30
+
31
+ function space(ns, create) {
32
+ let s = spaces.get(ns);
33
+ if (!s && create) {
34
+ s = { files: new Map(), hist: new Map(), importMap: null };
35
+ spaces.set(ns, s);
36
+ }
37
+ return s;
38
+ }
39
+
40
+ function urlFor(ns, name, v) {
41
+ return `andbox-vfs://${ns}/${name.split('/').map(encodeURIComponent).join('/')}?v=${v}`;
42
+ }
43
+
44
+ function parse(url) {
45
+ const u = new URL(url);
46
+ return {
47
+ ns: u.host,
48
+ name: decodeURIComponent(u.pathname.slice(1)),
49
+ v: u.searchParams.get('v'),
50
+ };
51
+ }
52
+
53
+ function lookup(ns, name) {
54
+ const s = spaces.get(ns);
55
+ if (!s) return null;
56
+ const bare = name.replace(/\.m?js$/, '');
57
+ for (const cand of [name, name + '.js', name + '.mjs', name + '/index.js', bare]) {
58
+ const f = s.files.get(cand);
59
+ if (f) return { name: cand, v: f.v };
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function matchSpec(specifier, mapping) {
65
+ if (mapping[specifier] !== undefined) return mapping[specifier];
66
+ let best = null;
67
+ for (const key of Object.keys(mapping)) {
68
+ if (key.endsWith('/') && specifier.startsWith(key) && (best === null || key.length > best.length)) best = key;
69
+ }
70
+ return best === null ? null : mapping[best] + specifier.slice(best.length);
71
+ }
72
+
73
+ function mapSpecifier(map, specifier, parentURL) {
74
+ if (!map) return null;
75
+ if (map.scopes) {
76
+ const scopes = Object.keys(map.scopes)
77
+ .filter((k) => parentURL.startsWith(k))
78
+ .sort((a, b) => b.length - a.length);
79
+ for (const k of scopes) {
80
+ const r = matchSpec(specifier, map.scopes[k]);
81
+ if (r !== null) return r;
82
+ }
83
+ }
84
+ return map.imports ? matchSpec(specifier, map.imports) : null;
85
+ }
86
+
87
+ function checkDenied(url, specifier) {
88
+ if (denyHostResources && (url.startsWith('node:') || url.startsWith('file:'))) {
89
+ const e = new Error(`Import of '${specifier}' is blocked in a hardened andbox sandbox`);
90
+ e.code = 'ERR_ANDBOX_BLOCKED';
91
+ throw e;
92
+ }
93
+ }
94
+
95
+ registerHooks({
96
+ resolve(specifier, context, nextResolve) {
97
+ const parent = context.parentURL;
98
+ if (specifier.startsWith(SCHEME)) {
99
+ const p = parse(specifier);
100
+ const hit = lookup(p.ns, p.name);
101
+ if (!hit) throw new Error(`Cannot find virtual module '${p.name}'`);
102
+ return {
103
+ url: urlFor(p.ns, hit.name, hit.v),
104
+ format: 'module',
105
+ shortCircuit: true,
106
+ };
107
+ }
108
+ if (typeof parent === 'string' && parent.startsWith(SCHEME)) {
109
+ const pp = parse(parent);
110
+ const relative = /^(\.\.?)?\//.test(specifier);
111
+ if (relative) {
112
+ const name = decodeURIComponent(new URL(specifier, parent).pathname.slice(1));
113
+ const hit = lookup(pp.ns, name);
114
+ if (!hit) throw new Error(`Cannot resolve '${specifier}' from virtual module '${pp.name}'`);
115
+ return { url: urlFor(pp.ns, hit.name, hit.v), format: 'module', shortCircuit: true };
116
+ }
117
+ const hit = lookup(pp.ns, specifier);
118
+ if (hit) return { url: urlFor(pp.ns, hit.name, hit.v), format: 'module', shortCircuit: true };
119
+ const mapped = mapSpecifier(spaces.get(pp.ns)?.importMap, specifier, parent);
120
+ if (mapped !== null) {
121
+ checkDenied(mapped, specifier);
122
+ return nextResolve(mapped, context);
123
+ }
124
+ }
125
+ const r = nextResolve(specifier, context);
126
+ checkDenied(r.url, specifier);
127
+ return r;
128
+ },
129
+ load(url, context, nextLoad) {
130
+ if (!url.startsWith(SCHEME)) return nextLoad(url, context);
131
+ const p = parse(url);
132
+ const src = spaces.get(p.ns)?.hist.get(p.name + '\0' + p.v);
133
+ if (src === undefined) throw new Error(`Virtual module '${p.name}' is not available (disposed?)`);
134
+ return { format: 'module', source: src, shortCircuit: true };
135
+ },
136
+ });
137
+
138
+ const api = {
139
+ /** Define/replace a file; returns its current URL. Unchanged source keeps its version. */
140
+ define(ns, name, source) {
141
+ const s = space(ns, true);
142
+ const cur = s.files.get(name);
143
+ if (cur && cur.src === source) return urlFor(ns, name, cur.v);
144
+ const v = ++counter;
145
+ s.files.set(name, { v, src: source });
146
+ s.hist.set(name + '\0' + v, source);
147
+ return urlFor(ns, name, v);
148
+ },
149
+ urlOf(ns, name) {
150
+ const f = spaces.get(ns)?.files.get(name);
151
+ return f ? urlFor(ns, name, f.v) : null;
152
+ },
153
+ sourceOf(ns, name) {
154
+ return spaces.get(ns)?.files.get(name)?.src ?? null;
155
+ },
156
+ has: (ns, name) => !!spaces.get(ns)?.files.has(name),
157
+ names: (ns) => [...(spaces.get(ns)?.files.keys() ?? [])],
158
+ setImportMap(ns, map) {
159
+ space(ns, true).importMap = map || null;
160
+ },
161
+ dispose(ns) {
162
+ spaces.delete(ns);
163
+ },
164
+ /** From now on, imports resolving to `node:` or `file:` URLs throw. */
165
+ denyHostResources() {
166
+ denyHostResources = true;
167
+ },
168
+ };
169
+ globalThis[KEY] = api;
170
+ return api;
171
+ }
172
+
173
+ /**
174
+ * The host-thread VFS, installed lazily (and only under Node, via the
175
+ * synchronous `process.getBuiltinModule`, which keeps `node:module` out of
176
+ * bundler graphs). Returns null where hooks are unavailable (browsers).
177
+ */
178
+ export function getHostNodeVfs() {
179
+ const registerHooks = globalThis.process?.getBuiltinModule?.('node:module')?.registerHooks;
180
+ return typeof registerHooks === 'function' ? installNodeVfs(registerHooks) : null;
181
+ }
@@ -12,15 +12,39 @@
12
12
  * `data:` URLs (which Node can `import()`) instead of `blob:` URLs. The
13
13
  * worker source itself is the same string the browser uses.
14
14
  *
15
+ * Virtual modules in the thread are served by `node-vfs.mjs` (in-thread
16
+ * `module.registerHooks`), so they can import each other relatively, by
17
+ * bare name, and through the sandbox import map. The VFS source is inlined
18
+ * into the prelude via `Function.prototype.toString`.
19
+ *
20
+ * Opt-in hardening (`nodeWorker.permissions`) spawns the thread with Node's
21
+ * permission model, an isolated `env`, a heap cap, captured stdio, and a
22
+ * stripped `process`. None of this makes a worker thread a security boundary;
23
+ * see the README's Security model.
24
+ *
15
25
  * `node:worker_threads` is loaded with a dynamic `import()` so the browser
16
26
  * entry never pulls a `node:` specifier into a bundle's static graph.
17
27
  */
18
28
 
19
- const PRELUDE = `
29
+ import { installNodeVfs } from './node-vfs.mjs';
30
+
31
+ function makePrelude({ harden }) {
32
+ return `
20
33
  const { parentPort } = require('node:worker_threads');
34
+ const __exit = process.exit.bind(process);
35
+ const __vfs = (${installNodeVfs.toString()})(require('node:module').registerHooks);
36
+ delete globalThis[Symbol.for('andbox.vfs')];
37
+ Object.defineProperty(globalThis, '__andboxNodeVirtual', {
38
+ configurable: true,
39
+ value(name, modules, importMap) {
40
+ __vfs.setImportMap('sb', importMap);
41
+ for (const [n, src] of modules) __vfs.define('sb', n, src);
42
+ return import(__vfs.urlOf('sb', name));
43
+ },
44
+ });
21
45
  globalThis.self = globalThis;
22
46
  globalThis.postMessage = (m) => parentPort.postMessage(m);
23
- globalThis.close = () => process.exit(0);
47
+ globalThis.close = () => __exit(0);
24
48
  parentPort.on('message', (data) => {
25
49
  try { const r = globalThis.onmessage?.({ data }); r?.catch?.((e) => { throw e; }); }
26
50
  catch (e) { parentPort.postMessage({ type: 'error', message: String(e?.message ?? e) }); }
@@ -31,7 +55,15 @@ globalThis.Blob = class AndboxNodeBlob {
31
55
  };
32
56
  URL.createObjectURL = (b) => 'data:text/javascript;base64,' + Buffer.from(b.text).toString('base64');
33
57
  URL.revokeObjectURL = () => {};
58
+
59
+ ${harden ? `
60
+ __vfs.denyHostResources();
61
+ for (const k of ['binding', '_linkedBinding', 'getBuiltinModule', 'dlopen', 'kill', 'abort', '_kill', 'reallyExit', 'mainModule', 'moduleLoadList']) {
62
+ try { delete process[k]; } catch {}
63
+ }
64
+ ` : ''}
34
65
  `;
66
+ }
35
67
 
36
68
  class NodeWebWorker {
37
69
  #thread;
@@ -40,8 +72,13 @@ class NodeWebWorker {
40
72
  onmessage = null;
41
73
  onerror = null;
42
74
 
43
- constructor(ThreadWorker, source) {
44
- const w = new ThreadWorker(PRELUDE + '\n' + source, { eval: true });
75
+ dead = false;
76
+ onstdio = null;
77
+
78
+ constructor(ThreadWorker, source, config) {
79
+ const w = new ThreadWorker(makePrelude({ harden: config.harden }) + '\n' + source, config.workerOptions);
80
+ if (config.workerOptions.stdout) w.stdout.on('data', (d) => this.onstdio?.('stdout', String(d)));
81
+ if (config.workerOptions.stderr) w.stderr.on('data', (d) => this.onstdio?.('stderr', String(d)));
45
82
  w.on('message', (data) => {
46
83
  const ev = { data };
47
84
  this.onmessage?.(ev);
@@ -49,6 +86,7 @@ class NodeWebWorker {
49
86
  });
50
87
  w.on('error', (e) => this.onerror?.({ message: e?.message ?? String(e) }));
51
88
  w.on('exit', (code) => {
89
+ this.dead = true;
52
90
  if (!this.#terminated && code !== 0) {
53
91
  this.onerror?.({ message: `worker exited with code ${code}` });
54
92
  }
@@ -61,6 +99,9 @@ class NodeWebWorker {
61
99
  }
62
100
  addEventListener(type, fn) { if (type === 'message') this.#listeners.add(fn); }
63
101
  removeEventListener(type, fn) { if (type === 'message') this.#listeners.delete(fn); }
102
+ /** Let the process exit while the thread is idle / keep it alive. */
103
+ ref() { if (!this.dead) this.#thread.ref(); }
104
+ unref() { if (!this.dead) this.#thread.unref(); }
64
105
  terminate() {
65
106
  this.#terminated = true;
66
107
  this.#listeners.clear();
@@ -81,7 +122,7 @@ export function isNodeRuntime() {
81
122
  *
82
123
  * @returns {Promise<(source: string) => object>}
83
124
  */
84
- export async function createNodeWorkerFactory() {
125
+ export async function createNodeWorkerFactory(options = {}) {
85
126
  if (!isNodeRuntime()) {
86
127
  throw new Error("createNodeWorkerFactory() requires Node (node:worker_threads); in a browser use mode: 'worker'.");
87
128
  }
@@ -89,5 +130,36 @@ export async function createNodeWorkerFactory() {
89
130
  // `node:` scheme) must not try to resolve this browser-irrelevant import.
90
131
  const specifier = 'node:' + 'worker_threads';
91
132
  const { Worker: ThreadWorker } = await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier);
92
- return (source) => new NodeWebWorker(ThreadWorker, source);
133
+ const config = resolveNodeWorkerConfig(options);
134
+ return (source) => new NodeWebWorker(ThreadWorker, source, config);
135
+ }
136
+
137
+ const DEFAULT_HARDENED_MEMORY_MB = 256;
138
+
139
+ /**
140
+ * Turn the public `nodeWorker` options into `Worker` constructor
141
+ * options.
142
+ */
143
+ export function resolveNodeWorkerConfig(options = {}) {
144
+ const { permissions = false, env, maxMemoryMb, resourceLimits, execArgv, captureStdio } = options;
145
+ if (maxMemoryMb !== undefined && !(Number.isFinite(maxMemoryMb) && maxMemoryMb > 0)) {
146
+ throw new Error('nodeWorker.maxMemoryMb must be a positive number (megabytes)');
147
+ }
148
+ const workerOptions = { eval: true };
149
+ const memory = maxMemoryMb ?? (permissions ? DEFAULT_HARDENED_MEMORY_MB : undefined);
150
+ if (memory !== undefined || resourceLimits) {
151
+ workerOptions.resourceLimits = {
152
+ ...(memory !== undefined ? { maxOldGenerationSizeMb: memory } : {}),
153
+ ...resourceLimits,
154
+ };
155
+ }
156
+ if (env !== undefined) workerOptions.env = env;
157
+ else if (permissions) workerOptions.env = {};
158
+ const argv = [...(permissions ? ['--permission'] : []), ...(execArgv ?? [])];
159
+ if (argv.length) workerOptions.execArgv = argv;
160
+ if (captureStdio ?? permissions) {
161
+ workerOptions.stdout = true;
162
+ workerOptions.stderr = true;
163
+ }
164
+ return { workerOptions, harden: permissions };
93
165
  }
package/src/sandbox.mjs CHANGED
@@ -231,9 +231,7 @@ async function createServiceWorkerSandbox(options = {}) {
231
231
  const initialFiles = { ...files };
232
232
  if (registry) {
233
233
  for (const path of registry.paths()) {
234
- const url = registry.resolve(path);
235
- const res = await fetch(url);
236
- initialFiles[path] = { body: await res.text(), contentType: guessContentType(path) };
234
+ initialFiles[path] = { body: registry.source(path), contentType: guessContentType(path) };
237
235
  }
238
236
  }
239
237
 
@@ -307,6 +305,8 @@ async function createServiceWorkerSandbox(options = {}) {
307
305
  * @property {(level: string, ...args: string[]) => void} [onConsole] - Console output handler
308
306
  */
309
307
 
308
+ const SUPPORTED_MODES = ['worker', 'node-worker', 'inline', 'data-uri', 'service-worker'];
309
+
310
310
  /**
311
311
  * Create a new sandboxed runtime.
312
312
  *
@@ -314,7 +314,13 @@ async function createServiceWorkerSandbox(options = {}) {
314
314
  * @returns {{ execute: Function, terminate: Function } | Promise<{ evaluate: Function, defineModule: Function, dispose: Function, isDisposed: () => boolean }> | Promise<{ scriptURL: string, scope: string, define: Function, remove: Function, dispose: Function, isDisposed: () => boolean }>}
315
315
  */
316
316
  export function createSandbox(options = {}) {
317
- const mode = options.mode || 'worker';
317
+ const mode = options.mode ?? 'worker';
318
+ if (!SUPPORTED_MODES.includes(mode)) {
319
+ throw new Error(
320
+ `Unknown sandbox mode ${typeof mode === 'string' ? `'${mode}'` : String(mode)}. ` +
321
+ `Supported modes: ${SUPPORTED_MODES.map((m) => `'${m}'`).join(', ')}.`
322
+ );
323
+ }
318
324
  if (mode === 'inline') return createInlineSandbox(options);
319
325
  if (mode === 'data-uri') return createDataUriSandbox(options);
320
326
  if (mode === 'service-worker') return createServiceWorkerSandbox(options);
@@ -329,14 +335,25 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
329
335
  baseURL = typeof location !== 'undefined' ? location.href : 'https://andbox.local/',
330
336
  policy,
331
337
  onConsole,
338
+ nodeWorker,
339
+ unref = false,
332
340
  } = options;
333
341
 
334
342
  // Node mode: no global Worker (and no blob: worker URLs) -> node:worker_threads.
335
343
  // An explicit workerFactory always wins; 'node-worker' forces Node; the
336
344
  // default selects it only when there is no global Worker under Node.
337
345
  let workerFactory = options.workerFactory || null;
346
+ let usingNode = false;
338
347
  if (!workerFactory && (forceNode || (typeof Worker === 'undefined' && isNodeRuntime()))) {
339
- workerFactory = await createNodeWorkerFactory();
348
+ workerFactory = await createNodeWorkerFactory(nodeWorker);
349
+ usingNode = true;
350
+ }
351
+ if (nodeWorker && !usingNode) {
352
+ // A security option that silently does nothing is worse than an error.
353
+ throw new Error(
354
+ 'The nodeWorker option only applies to the built-in Node worker_threads mode; ' +
355
+ 'it cannot be honoured with a browser Worker or a custom workerFactory.'
356
+ );
340
357
  }
341
358
 
342
359
  // Gate capabilities with rate limits
@@ -351,6 +368,17 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
351
368
  let worker = null;
352
369
  let workerBlobURL = null;
353
370
 
371
+ // `unref`: the thread only keeps the host process alive while work is in
372
+ // flight (startup, evaluate, defineModule); idle, it lets the process exit.
373
+ let activeOps = 0;
374
+ function beginOp() {
375
+ activeOps++;
376
+ worker?.ref?.();
377
+ }
378
+ function endOp() {
379
+ if (--activeOps === 0 && unref) worker?.unref?.();
380
+ }
381
+
354
382
  // Pending evaluations
355
383
  const pending = new Map(); // id -> { resolve, reject, timer }
356
384
 
@@ -361,6 +389,7 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
361
389
  if (workerFactory) {
362
390
  worker = workerFactory(source);
363
391
  attachWorkerHandlers();
392
+ worker.onstdio = (stream, text) => activeConsoleHandler?.(stream, text);
364
393
  return;
365
394
  }
366
395
  const blob = new Blob([source], { type: 'application/javascript' });
@@ -471,9 +500,15 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
471
500
  }
472
501
 
473
502
  async function restartWorker() {
474
- terminateWorker();
475
- createWorker();
476
- await configureWorker();
503
+ beginOp();
504
+ try {
505
+ terminateWorker();
506
+ createWorker();
507
+ worker.ref?.();
508
+ await configureWorker();
509
+ } finally {
510
+ endOp();
511
+ }
477
512
  }
478
513
 
479
514
  // ── Public API ──
@@ -487,7 +522,8 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
487
522
  */
488
523
  async function evaluate(code, opts = {}) {
489
524
  if (disposed) throw new Error('Sandbox is disposed');
490
- if (!worker) await restartWorker();
525
+ if (!worker || worker.dead) await restartWorker();
526
+ beginOp();
491
527
 
492
528
  // Random (not sequential) id so code running during one evaluate() call
493
529
  // can't guess the id of a concurrent evaluate() on the same worker and
@@ -516,6 +552,7 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
516
552
  if (opts.signal) {
517
553
  if (opts.signal.aborted) {
518
554
  if (timer) clearTimeout(timer);
555
+ endOp();
519
556
  throw makeAbortError();
520
557
  }
521
558
  opts.signal.addEventListener('abort', () => {
@@ -533,10 +570,10 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
533
570
  worker.postMessage({ type: 'evaluate', id, code });
534
571
 
535
572
  // Restore console handler when evaluation completes
536
- if (opts.onConsole) {
537
- return promise.finally(() => { activeConsoleHandler = prevConsoleHandler; });
538
- }
539
- return promise;
573
+ return promise.finally(() => {
574
+ if (opts.onConsole) activeConsoleHandler = prevConsoleHandler;
575
+ endOp();
576
+ });
540
577
  }
541
578
 
542
579
  /**
@@ -551,6 +588,7 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
551
588
  virtualModules.set(name, source);
552
589
 
553
590
  if (worker) {
591
+ beginOp();
554
592
  const { promise, resolve } = makeDeferred();
555
593
  const handler = ({ data }) => {
556
594
  if (data.type === 'moduleDefined' && data.name === name) {
@@ -560,7 +598,7 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
560
598
  };
561
599
  worker.addEventListener('message', handler);
562
600
  worker.postMessage({ type: 'defineModule', name, source });
563
- await promise;
601
+ try { await promise; } finally { endOp(); }
564
602
  }
565
603
  }
566
604
 
@@ -594,8 +632,13 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
594
632
  }
595
633
 
596
634
  // ── Initialize ──
597
- createWorker();
598
- await configureWorker();
635
+ beginOp();
636
+ try {
637
+ createWorker();
638
+ await configureWorker();
639
+ } finally {
640
+ endOp();
641
+ }
599
642
 
600
643
  return {
601
644
  evaluate,
@@ -10,12 +10,14 @@
10
10
  */
11
11
 
12
12
  import { resolveWithImportMap } from './import-map-resolver.mjs';
13
+ import { getHostNodeVfs } from './node-vfs.mjs';
13
14
 
14
15
  /**
15
16
  * @typedef {Object} VirtualModuleRegistry
16
17
  * @property {(path: string) => string | null} resolve
17
18
  * @property {(specifier: string, parentPath?: string) => string | null} resolveSpecifier
18
19
  * @property {(path: string, source: string) => string} define
20
+ * @property {(path: string) => string | null} source
19
21
  * @property {(path: string) => boolean} has
20
22
  * @property {() => string[]} paths
21
23
  * @property {() => void} dispose
@@ -23,18 +25,43 @@ import { resolveWithImportMap } from './import-map-resolver.mjs';
23
25
  */
24
26
 
25
27
  /**
26
- * Create a registry of in-memory files, each backed by its own `blob:` URL.
28
+ * Create a registry of in-memory files.
29
+ *
30
+ * In a browser each file is backed by its own `blob:` URL. Under Node, whose
31
+ * ESM loader cannot `import()` a `blob:` URL, the registry is backed by an
32
+ * in-thread module hook instead (see `node-vfs.mjs`): `resolve(path)`
33
+ * returns an `andbox-vfs://<id>/<path>?v=<n>` URL that Node *can* `import()`,
34
+ * and files in the registry can import each other by relative path
35
+ * (`./util.js`), by bare name, and through the registry's import map.
36
+ * Call `dispose()` to drop the files.
37
+ * `options.backend` is `'auto'` (default: Node hooks under Node, blob
38
+ * elsewhere), `'blob'` or `'node'`.
27
39
  *
28
40
  * @param {Record<string, string>} [files] - path → source text
29
- * @param {{ importMap?: { imports?: Record<string,string>, scopes?: Record<string,Record<string,string>> } }} [options]
41
+ * @param {{ backend?: 'auto' | 'blob' | 'node', importMap?: { imports?: Record<string,string>, scopes?: Record<string,Record<string,string>> } }} [options]
30
42
  * @returns {VirtualModuleRegistry}
31
43
  */
32
44
  export function createVirtualModuleRegistry(files = {}, options = {}) {
33
45
  const importMap = options.importMap || null;
34
- const urls = new Map(); // path -> blob: URL
46
+ const urls = new Map(); // path -> blob: / andbox-vfs: URL
47
+ const sources = new Map(); // path -> source text
48
+ const backend = options.backend ?? 'auto';
49
+ if (!['auto', 'blob', 'node'].includes(backend)) {
50
+ throw new Error(`Unknown registry backend '${backend}'; use 'auto', 'blob' or 'node'`);
51
+ }
52
+ const vfs = backend === 'blob' ? null : getHostNodeVfs();
53
+ if (backend === 'node' && !vfs) throw new Error("backend: 'node' requires Node (module.registerHooks)");
54
+ const ns = vfs ? Math.random().toString(36).slice(2, 10) + Date.now().toString(36) : null;
35
55
  let disposed = false;
56
+ if (vfs) vfs.setImportMap(ns, importMap);
36
57
 
37
58
  function blobify(path, source) {
59
+ sources.set(path, source);
60
+ if (vfs) {
61
+ const url = vfs.define(ns, path, source);
62
+ urls.set(path, url);
63
+ return url;
64
+ }
38
65
  const blob = new Blob([source], { type: 'text/javascript' });
39
66
  const url = URL.createObjectURL(blob);
40
67
  urls.set(path, url);
@@ -70,10 +97,22 @@ export function createVirtualModuleRegistry(files = {}, options = {}) {
70
97
  */
71
98
  function define(path, source) {
72
99
  assertNotDisposed();
73
- if (urls.has(path)) URL.revokeObjectURL(urls.get(path));
100
+ if (!vfs && urls.has(path)) URL.revokeObjectURL(urls.get(path));
74
101
  return blobify(path, source);
75
102
  }
76
103
 
104
+ /**
105
+ * The source text registered for a path (works in every runtime, including
106
+ * Node where the URL is not fetchable).
107
+ *
108
+ * @param {string} path
109
+ * @returns {string | null}
110
+ */
111
+ function source(path) {
112
+ assertNotDisposed();
113
+ return sources.get(path) ?? null;
114
+ }
115
+
77
116
  /** @param {string} path */
78
117
  function has(path) {
79
118
  return urls.has(path);
@@ -129,14 +168,17 @@ export function createVirtualModuleRegistry(files = {}, options = {}) {
129
168
  function dispose() {
130
169
  if (disposed) return;
131
170
  disposed = true;
132
- for (const url of urls.values()) URL.revokeObjectURL(url);
171
+ if (vfs) vfs.dispose(ns);
172
+ else for (const url of urls.values()) URL.revokeObjectURL(url);
133
173
  urls.clear();
174
+ sources.clear();
134
175
  }
135
176
 
136
177
  return {
137
178
  resolve,
138
179
  resolveSpecifier,
139
180
  define,
181
+ source,
140
182
  has,
141
183
  paths,
142
184
  dispose,
@@ -33,6 +33,10 @@ export function makeWorkerSource() {
33
33
  let importMap = { imports: {}, scopes: {} };
34
34
  let baseURL = 'https://andbox.local/';
35
35
  const virtualModules = new Map();
36
+ // Node mode only: the adapter provides a loader that lets virtual modules
37
+ // import each other. Captured once and removed from the global scope.
38
+ const nodeVirtual = globalThis.__andboxNodeVirtual;
39
+ try { delete globalThis.__andboxNodeVirtual; } catch {}
36
40
 
37
41
  // ── Import Map Resolver (inlined) ──
38
42
  function resolveWithImportMap(specifier, map, parentURL) {
@@ -69,6 +73,7 @@ function matchSpec(specifier, mapping) {
69
73
  async function sandboxImport(specifier) {
70
74
  // 1. Virtual module
71
75
  if (virtualModules.has(specifier)) {
76
+ if (nodeVirtual) return await nodeVirtual(specifier, virtualModules, importMap);
72
77
  const src = virtualModules.get(specifier);
73
78
  const blob = new Blob([src], { type: 'application/javascript' });
74
79
  const url = URL.createObjectURL(blob);
@@ -162,10 +167,12 @@ self.onmessage = async ({ data: msg }) => {
162
167
  case 'evaluate': {
163
168
  const fwdConsole = makeForwardingConsole(msg.id);
164
169
  try {
165
- // Wrap in async function for top-level await
170
+ // Wrap in async function for top-level await. The newlines before and
171
+ // after the user code matter: without the trailing one, code ending in
172
+ // a // line comment swallows the closing brace (andbox#23).
166
173
  const asyncFn = new Function(
167
174
  'sandboxImport', 'host', 'console',
168
- \`return (async () => { \${msg.code} })();\`
175
+ \`return (async () => {\\n\${msg.code}\\n})();\`
169
176
  );
170
177
  const result = await asyncFn(sandboxImport, host, fwdConsole);
171
178
  self.postMessage({ type: 'result', id: msg.id, success: true, value: serialize(result) });