ADR-0010: Harden the plugin sandbox with Node's Permission Model
- Status: Accepted
- Date: 2026-07-01
- Deciders: Founding engineer
- Supersedes: the execution mechanism of ADR-0009 (the trust model in ADR-0008 and the integrity/consent gate are unchanged)
Context
ADR-0009 shipped plugin execution in a worker_threads worker with no ambient environment, and was explicit that a worker is not a hard boundary — a worker can still require('node:fs') and write to disk. Integrity + consent carried the real weight; the worker was defense-in-depth only.
Node's Permission Model (--permission, stable in Node 24) closes that gap for the filesystem and process-spawning surface. Under --permission, fs.read, fs.write, child_process, worker, native addons, and WASI are deny-by-default and only granted by explicit --allow-* flags — enforced by the runtime, not by plugin cooperation. Verified: a write under --permission without --allow-fs-write throws ERR_ACCESS_DENIED.
Decision
Run each approved plugin in a child node process under the Permission Model, not a worker:
node --permission --input-type=module -e <runner>- No filesystem at all.
--permissionwith no--allow-*grants: nofs.read, nofs.write, nochild_process, noworker, no addons, no WASI. The plugin cannot read or write the disk, spawn processes, start workers, or load native code — all enforced by the runtime. - The engine reads the source; the plugin imports a
data:URL. The engine (which is trusted and has already read the entry to compute its integrity hash) sends the plugin's source over stdin. The child imports it as adata:text/javascript,…URL, which needs no filesystem access. This is why the child can run with zero fs permissions. - No ambient environment. The child is spawned with
env: {}. - Data in/out over stdio. stdin carries
{ source, repo, capabilities }as JSON; the plugin returns{ outputs: [{ path, body }] }on stdout. Outputs still flow through the engine's ownership/managed pipeline (ADR-0004) — the plugin never writes files itself. - Bounded. A timeout kills a plugin that hangs.
Because the plugin is imported as a data: URL, the code that runs must be a single self-contained module. ADR-0011 lifts the authoring constraint by bundling the plugin engine-side (multi-file + deps) before it reaches this sandbox, and hashing the bundle.
Integrity + consent (ADR-0008) remain the primary gate: a plugin runs only if .repospec/plugins.lock approves it at a matching integrity hash. The Permission Model is now a genuine second layer, not just cooperative isolation.
Consequences
Positive
- OS-enforced: filesystem writes and process/worker spawning are blocked by the runtime, not by trusting the plugin. A materially stronger boundary than ADR-0009.
- Portable — no native deps, no per-OS sandbox config; works wherever Node 24 runs.
- The engine keeps sole authority over writes and the ownership model.
Negative / trade-offs
- Network is only partially gated. A plugin not approved for the
networkcapability hasglobalThis.fetchandWebSocketreplaced with throwing stubs (blocking the common modern path). But the Permission Model has no network permission, and the airtight approach — amoduleresolve hook refusingnode:net/node:http/… — requires--allow-worker, which this sandbox denies. So the low-levelnode:net/node:httpbuiltins remain reachable in-process. Full network isolation needs an OS sandbox (future); integrity + consent remain the control for the residual. - Plugin entries must be single self-contained modules — the
data:URL cannot resolve relative ornode_modulesimports. Bundle dependencies. - Subprocess startup is slightly heavier than a worker.
Neutral / follow-ups
- npm resolution (shipped): a declared plugin resolves from a local
.repospec/plugins/<id>/or an installed npm package<id>shipping arepospec-plugin.yaml. Resolution and source-reading happen engine-side (with full fs); the sandboxed child still receives only the source, so the zero-fs guarantee holds. - Airtight network gating via an OS sandbox that covers sockets.
- A first-class bundling step so multi-file / dependency-bearing plugins can run.
Alternatives considered
- Keep the worker (ADR-0009). Rejected — no OS-enforced fs boundary.
- WASM. Rejected as the default — strongest isolation but forces authors to compile to WASM; kept as possible future hardening.
- OS sandbox (seccomp /
sandbox-exec). Rejected as the default — non-portable and heavy; the Permission Model gives most of the fs/process benefit portably.