andbox
andbox runs JavaScript in a dedicated Worker — a Web Worker in browsers, a node:worker_threads thread under Node — 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.
Pick the mode by how much you trust the code. The default Worker mode organises and throttles code you trust: it keeps well-behaved code from touching the DOM or host globals by accident, and gives you rate limits, timeouts, and a kill switch. It is not a boundary against code that tries to escape (the platform import() operator and timing channels remain, and under Node a worker thread shares the host process). For untrusted code use mode: 'wasm' (createSandbox({ untrusted: true })), where host.call() is the only way out; for code that needs a DOM, mode: 'iframe' puts it behind a browser-enforced origin boundary. See Security model before using andbox to run code you don’t trust.
Zero dependencies. Uses only Web Workers and standard browser APIs in browsers, and node:worker_threads under Node (Node 26 or newer). mode: 'wasm' needs an optional, pinned QuickJS engine (Installing the engine).
Previously published as
andbox(last unscoped version 0.1.1). Same library, same API — the scoped package restarts its version line at 0.0.0: a new address and era, not a maturity signal.
Install
Section titled “Install”npm install @johnhenry/andbox<!-- In <head>, before any <script type="module"> or modulepreload. --><script type="importmap"> { "imports": { "@johnhenry/andbox": "https://cdn.jsdelivr.net/npm/@johnhenry/[email protected]/src/index.mjs" } }</script><script type="module"> import * as andbox from "@johnhenry/andbox";</script>Or via CDN (no bundler needed):
import { createSandbox } from 'https://esm.sh/@johnhenry/andbox';Quick Start
Section titled “Quick Start”import { createSandbox } from '@johnhenry/andbox';
const sandbox = await createSandbox({ capabilities: { readFile: async (path) => { /* host-side file read */ }, writeFile: async (path, content) => { /* host-side file write */ }, }, importMap: { imports: { 'lodash': 'https://esm.sh/lodash', }, }, onConsole: (level, ...args) => console.log(`[sandbox:${level}]`, ...args),});
// Evaluate code in the sandboxconst result = await sandbox.evaluate(` const greeting = 'Hello from the sandbox!'; console.log(greeting);
// Call a host capability const content = await host.call('readFile', '/etc/hostname'); return content;`);
// Define a virtual moduleawait sandbox.defineModule('utils', ` export function add(a, b) { return a + b; }`);
// Import the virtual module from sandbox codeawait sandbox.evaluate(` const { add } = await sandboxImport('utils'); return add(2, 3); // 5`);
// Clean upawait sandbox.dispose();Execution modes
Section titled “Execution modes”andbox supports seven execution modes (any other mode throws an error listing these):
worker(default) – Runs in a dedicated Worker with an RPC bridge, import maps, virtual modules, and hard-kill timeout semantics. Since 0.1.0 the Worker’s network and messaging globals (fetch,WebSocket,importScripts,postMessage,self, …) are removed before evaluated code runs. Under Node, with no globalWorker, this picksnode:worker_threadsautomatically – see Running under Node. See Security model for what this does and doesn’t protect against.node-worker– Theworkermode onnode:worker_threads, forced. Selected automatically under Node when there is no globalWorker.wasm– Optional. Runs the code in QuickJS-ng compiled to WebAssembly inside the Worker (or worker thread), withhost.callas the only authority and real memory, stack, fuel and deadline limits. The mode for untrusted code;createSandbox({ untrusted: true })selects it. Seemode: 'wasm'.iframe(0.1.2) – Browser only. Runs the code in a sandboxed<iframe sandbox="allow-scripts" srcdoc>: an opaque origin with its own realm,windowanddocument, so the code can render real DOM (charts, canvas animations, HTML) that you mount in your page. Same API asworker. Seemode: 'iframe'.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.data-uri– Dynamicimport()via Blob URL (adata:URL under Node). Module-level separation without a Worker. Supports globals injection.service-worker– Not code execution at all: registers a Service Worker that serves an in-memorypath → contentmap with real HTTP-shaped fetch/navigation semantics. For hosting a small virtual multi-file site, not for running JS in isolation; browser only. See API reference and Security model.
Worker, node-worker and iframe modes can also give the sandbox a global fetch that goes through a function on the host, behind a required allowedHosts policy (0.2.0): see Mediated network.
// Inline mode (no Worker)const inline = createSandbox({ mode: 'inline', globals: { math: Math } });const result = await inline.execute('return math.sqrt(16)');
// Data-URI modeconst dataUri = createSandbox({ mode: 'data-uri', globals: { x: 42 } });const result = await dataUri.execute('print(x)');Running under Node
Section titled “Running under Node”Since 0.0.4 no Worker shim is needed. Under Node (engines.node >= 26), createSandbox() just works:
import { createSandbox } from '@johnhenry/andbox';
const sandbox = await createSandbox({ capabilities: { now: () => Date.now() } });await sandbox.evaluate('return await host.call("now")');await sandbox.dispose(); // worker threads keep the process alive until disposedMode selection. mode: 'worker' (the default) picks node:worker_threads when typeof Worker === 'undefined' and the runtime is Node; if a global Worker exists (a browser, or a shim you installed) that is used as before. mode: 'node-worker' forces the Node implementation. workerFactory: (source) => WorkerLike overrides both, and createNodeWorkerFactory() is exported for that purpose.
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. 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.
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: under Node, modules are cached per definition, so repeated sandboxImport(name) returns the same module instance until it is redefined.
Process lifetime. A live worker thread keeps the host process alive until dispose(). 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.
Opt-in hardening: nodeWorker. By default the thread behaves like any worker_threads thread: a copy of process.env, no memory cap, and full access to process, fs, child_process, and the rest. nodeWorker tightens that:
const sandbox = await createSandbox({ nodeWorker: { permissions: true, // permission model, isolated env, heap cap, ... // env: { MODE: 'x' }, // explicit env; with permissions the default is {} // maxMemoryMb: 128, // heap cap (default 256 with permissions: true) // resourceLimits: {...}, // raw worker_threads resourceLimits, merged on top // execArgv: [...], // extra thread flags, e.g. '--allow-fs-read=/data' // captureStdio: true, // route thread stdout/stderr to onConsole('stdout'|'stderr', text) },});permissions: true spawns the thread with --permission (so fs reads and child_process are denied with ERR_ACCESS_DENIED; grant paths with execArgv: ['--allow-fs-read=/dir']), gives it an isolated env ({} unless you pass one), caps the heap at 256 MB by default (an allocation loop kills the thread, the pending evaluate() rejects, and the next call starts a fresh thread), strips process.binding, _linkedBinding, getBuiltinModule, dlopen, kill, abort, reallyExit and mainModule, makes a resolve hook reject any import()/sandboxImport() that resolves to a node: or file: URL (data:, http(s): and virtual modules still work), and routes thread stdout/stderr to onConsole. Nested workers and native addons are not granted. The nodeWorker options throw when combined with a browser Worker or a custom workerFactory, rather than silently doing nothing.
Not supported / differences from the browser.
mode: 'service-worker'andmode: 'iframe'need a browser and reject under Node.mode: 'wasm'works under Node with no extra setup once the engine is installed, but rejectsnodeWorker.permissionsfor now.data-urimode uses adata:URL instead of a Blob URL.- A worker thread is not a security boundary – not by default and not with
nodeWorker.permissions. See Security model.
Execution model
Section titled “Execution model”In a browser, code runs inside a Web Worker created from a Blob URL (under Node, inside a worker_threads thread — see Running under Node). This gets you, for free, against code that isn’t specifically trying to defeat it:
- No DOM access – Workers are inherently isolated from the document (a Node worker thread has no DOM to begin with)
- No direct host object references – only what’s explicitly passed in (capabilities, globals, import map entries) is reachable, so ordinary code can’t accidentally touch host-side state
- Hard kill – on timeout, the Worker is
terminate()d and a fresh one is created for the next call - Virtual modules – modules defined via
defineModule()are available viasandboxImport() - Capability rate limits –
gateCapabilities()caps calls/concurrency/payload size per capability, for cooperative callers
Security model
Section titled “Security model”andbox’s Worker modes are not a boundary against code that is actively trying to escape. If you’re running code you don’t fully trust, read this section before you rely on capabilities/policy/createNetworkFetch for anything.
Pick the mode by how much you trust the code:
workerandnode-workerare for trusted or semi-trusted code (your own scripts, plugins from known authors, LLM output you review). They organise and throttle what the code does; they do not contain a determined attacker. Still reachable from code in these modes: the platformimport()operator (fetches and runs remote code, an exfiltration channel;allowedImportHostsonly governssandboxImport()), timing andSharedArrayBuffer/Atomicsside channels, the Worker’s shared realm and heap, any global a future platform adds that is not on the deny-list, and under Nodeprocess,requireand the rest of the Node API.iframeis for code that needs the DOM, trusted or semi-trusted, that you want kept away from your page. The browser enforces an origin boundary: the frame runs in an opaque origin and a separate realm, so it cannot read your document, cookies, storage or JavaScript objects, and it reaches you only throughhost.call()and the values it returns. It does not limit what the code does with its own window: network, CPU and memory are its own. Seemode: 'iframe'.wasmis the mode for untrusted code, andcreateSandbox({ untrusted: true })selects it (and throws or rejects instead of falling back if it is unavailable, or if combined with anothermode). The code runs in QuickJS compiled to WebAssembly with no ambient authority: nofetch,import()of URLs, timers,processorrequireexist in that engine, and its only way out ishost.call()throughcapabilities,policyand the gate. It has real limits (fuel,memoryBytes,stackBytes,deadlineMs) and a hardterminate()backstop. It does not guarantee that your own capabilities are safe, protection from engine or WebAssembly-runtime bugs, cancellation of an in-flight capability when the cooperative deadline fires (andbox#35), or Node-level hardening. Seemode: 'wasm'.
What andbox guarantees:
- No DOM access. Worker-mode code executes in a real Worker global scope, which has no
document,window, or other DOM references – a platform property of Workers, not something andbox enforces itself. - No implicit host object references. Only what you explicitly pass in (
capabilities,globals, import map entries) is reachable from sandboxed code, so ordinary (non-adversarial) code cannot accidentally read or mutate host-side state it wasn’t given a reference to. - Hard kill on timeout.
evaluate()calls that exceedtimeoutMsterminate()the Worker outright and start a fresh one for the next call – a real kill, not a cooperative cancellation the running code could ignore. See “still yours” below for what a kill does not undo. - The capability gate cannot be walked around via the prototype chain.
gateCapabilities()builds the gated object withObject.create(null), sohost.call('constructor', ...)cannot resolve throughObject.prototypeto the real globalObjectconstructor; the host resolves names through aMapand rejects anything that was not explicitly granted. First fixed in 0.0.1, hardened in 0.0.9; see andbox#5. - Protocol messages can’t be forged by guessing. Evaluate and capability ids are random UUIDs, and each
resultmust echo a per-evaluate nonce (0.0.9, andbox#9). createNetworkFetch()’s allowlist is redirect-safe. Requests are made withredirect: 'manual'and any redirect response is rejected outright, so an allowlisted host cannot silently redirect a caller to a non-allowlisted one. See andbox#6.gateCapabilities()enforces call/argument-size/concurrency caps per capability, for cooperative callers that stay within the capabilities you actually granted.- (
mode: 'iframe') An opaque origin and a separate realm, enforced by the browser. The frame issandbox="allow-scripts"withoutallow-same-origin(refused unlessdangerouslyAllowSameOrigin: true). Its code getsSecurityErrorforparent.document,top.location,parent.localStorageand its ownlocalStorage, has no access to your cookies, and shares no objects with your page.
What is still yours:
- Worker-global APIs: partly removed, not contained. Since 0.1.0 the worker prelude deletes
fetch,WebSocket,WebSocketStream,WebTransport,EventSource,XMLHttpRequest,Worker,SharedWorker,importScripts,indexedDB,caches,BroadcastChannel,postMessageandselffrom the global scope before any evaluated code runs (withnetworkset,fetchis replaced by the host-backed shim instead), shadows those names for evaluated code, and gives evaluated code a throwawaythis. A script no longer gets them by name, throughglobalThis, indirectevalorFunction. This is hardening, not a boundary. Still reachable: the platformimport()operator (syntax: it cannot be deleted or shadowed, can fetch and execute remote code, and is an exfiltration channel), timing andSharedArrayBuffer/Atomicsside channels, anything the platform adds later that is not on the list, and under Nodeprocess,requireand the rest of the Node API. For hostile code usemode: 'wasm'. See andbox#10. - Under Node, a worker thread is not a security boundary. It shares the process with the host; by default it inherits
process.envand can reachprocess(includingprocess.bindingandprocess.getBuiltinModule('fs')) andimport('node:child_process'). The opt-innodeWorker.permissionshardening raises the cost of casual abuse but does not restrict the network or CPU use, and theprocessstripping and import hook are denylists, not a proof. For untrusted code on a server, add OS-level isolation. sandboxImport()remote imports: allowed unlessallowedImportHostsis provided, and onlysandboxImport()is governed. WithallowedImportHostsunset, absolute and protocol-relativehttp(s)specifiers load from any host (0.1.0 denied them by default; 0.1.1 reverted that). PassallowedImportHosts: [...]to restrict to those hostnames plusbaseURL’s own host, orallowedImportHosts: []to deny all remote imports. Import-map targets and virtual modules are host-authored and unaffected. The check cannot see inside a module once loaded and cannot stop the platformimport()operator.mode: 'wasm'never fetches URLs at all. See andbox#7.network: no network unless you list hosts.network.allowedHostsis required (0.2.0, andbox#43): a list of hostnames, a function asked about every request and redirect hop, or the explicit'*', which hands the whole decision to yourfetch. Settingnetworkwithout it throws, so it can no longer mean “every host your function will fetch” by omission.networknarrowsfetchto whatallowedHostsand your host function allow; it does not close the other routes out. Each request is checked againstallowedHostson the host, butimport()(andsandboxImport()unless restricted) can still reach the network in worker mode, and the frame’s own network APIs remain in iframe mode unlesscspblocks them. Your function talks to the network with the host’s network position: on a server that includeslocalhost, private addresses and cloud metadata endpoints such as169.254.169.254, so prefer anallowedHostslist and refuse private addresses in a function or'*'policy. With'*', redirects are whatever yourfetchdoes. See Mediated network.- A timeout cannot undo in-flight host-side effects; it can ask them to stop. Since 0.0.10, when the Worker is terminated (timeout, an aborted
evaluate(),dispose(), a crash) every capability call still in flight seesthis.signalabort, and its late result is dropped rather than delivered. Cancellation is cooperative: a capability that ignoresthis.signalstill runs to completion. Write effectful capabilities asfunctions (not arrows), pass the signal on (fetch(url, { signal: this.signal })), and keep them idempotent. Inmode: 'wasm', a cooperativedeadlineMsends the evaluation without terminating the Worker, so the signal does not abort in that case. See andbox#8. mode: 'iframe': the origin boundary is the whole guarantee. The frame’s network, CPU and memory, the sandbox tokens you enable, and the UI it draws are still yours. Seemode: 'iframe'.mode: 'service-worker'does not provide isolation by merely existing. It’s a hosting mechanism – a real Service Worker, same-origin by default, serving yourfilesmap. Content served through it can see and touch its own origin like any same-origin page. For content you don’t fully trust, point it at a genuinely separate origin. See andbox#14.- The Service Worker does not control the very first navigation into its scope. Don’t navigate anything into
scopeuntil the promise returned bycreateSandbox({ mode: 'service-worker' })resolves; after that every request is intercepted from the first byte.
If you need to run untrusted/adversarial code, use createSandbox({ untrusted: true }) (mode: 'wasm') and, for hostile multi-tenant workloads, 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 are for organizing and throttling code you already trust, not for containing code you don’t.
Threat model by mode
Section titled “Threat model by mode”What each mode is built to stop, and what it is not. “Hostile” means code actively trying to escape or abuse the host.
worker / node-worker |
wasm |
iframe |
|
|---|---|---|---|
| Runs in | The Worker’s own JS engine (new Function) |
QuickJS-ng compiled to WebAssembly, inside the Worker / worker thread | The browser’s JS engine, in a sandboxed opaque-origin frame |
Reaching fetch, WebSocket, importScripts, indexedDB, postMessage |
Removed from the global scope by the prelude (0.1.0); with network, fetch is a shim that goes through your host function. Deny-list hardening only: import(), timing channels and (Node) process/require remain. |
Not possible. The engine has no such globals. | Available, as the frame’s own (null-origin) APIs (with network, fetch is the host-backed shim); restrict the network with csp. Your page, cookies and storage are not reachable. |
| Forging protocol messages to the host | postMessage/self are removed; ids are random UUIDs and each result must echo a per-evaluate nonce. |
Not possible. The guest has no postMessage or self. |
Only over its own MessagePort, with the same random ids and nonce. |
sandboxImport of arbitrary URLs / Node builtins |
Remote URLs allowed unless allowedImportHosts is provided; the raw import() operator is unrestricted; Node builtins are blocked only with nodeWorker.permissions. |
Refused: only virtual modules resolve; no URL is fetched. | As worker mode; requests are cross-origin from null. csp can restrict import() too. |
Prototype-chain names via host.call |
Closed by the capability gate. | Same gate, plus the guest never sees host objects. | Same gate; separate realm, so no shared intrinsics. |
| Infinite loops | terminate() after timeoutMs, then a new Worker. |
Deterministic fuel and a wall-clock deadlineMs stop it without a respawn; terminate() remains the backstop. |
Async hangs: frame removed after timeoutMs. Synchronous loops: only where the frame is out of process (desktop Chrome); elsewhere they freeze the page. |
| Memory exhaustion | Browser: nothing but the tab limit. Node: opt-in nodeWorker.maxMemoryMb. |
Guest heap cap plus a hard cap on the engine’s linear memory. | Nothing but the browser’s per-process/tab limit. |
| Deep recursion | Engine stack limit of the host JS engine. | stackBytes; overflow is a catchable RangeError. |
Engine stack limit. |
| Capability abuse | gateCapabilities() rate and size limits (cooperative callers). |
Same. | Same. |
inline and data-uri provide no isolation at all.
Status
Section titled “Status”Small and usable in both browsers and Node: zero runtime dependencies, engines.node >= 26 (browser use needs only standard Web Worker APIs; mode: 'wasm' adds an optional, pinned engine). Runnable examples live in the repo’s
examples/
directory: 01-05 and 07 (wasm mode) run under Node; the Service Worker (06), wasm-in-the-browser (08) and iframe (09) demos are browser-only. Every release is listed in the changelog.
License
Section titled “License”MIT