Sandboxing
Novaterra runs two kinds of code it did not write. The first is model-authored code —
code.execute, the scaffold build step — which has been sandboxed since the beginning, originally
in Docker and now, by default, in a WASM runtime that ships with the repository (see
Where model-authored code runs now below, and
Security & privacy). The second is
third-party plugin code from the marketplace, and until @novaterra/sandbox
existed there was no isolation for it at all.
That gap was the serious one. A registered skill runs inside the API process. Before this package,
a marketplace plugin could require('child_process'), read process.env.OPENROUTER_API_KEY,
SESSION_SECRET and ENCRYPTION_KEY, open novaterra.db directly, and reach any host on the
network — with no manifest, no grant and no audit trail involved, because none of those things were
enforced by anything at runtime. The manifest’s capabilities list described what a plugin said
it would do; nothing stopped it doing more.
This package is wired into the plugin host: PluginHost.install()
(packages/skills/src/plugins/host.ts) refuses to register a skill from any non-builtin source
without an entryPath, and whenever one is present the skill’s run becomes a call into
runSandboxed() — a fresh SandboxPolicy is rebuilt from the manifest and the owner’s grant on
every invocation, not cached at install time, so a revoked capability takes effect on the very
next call rather than the next restart. See
Plugins and extensibility for the platform story and
Isolation and sandboxing for exactly what
that does and doesn’t cover for a plugin author and an installing owner.
The three tiers
packages/sandbox provides three, chosen per plugin by risk. They are genuinely different
boundaries, not three settings of the same one, and the differences are the entire point.
| Tier | Mechanism | Boundary | Use for |
|---|---|---|---|
| A — worker | node:worker_threads | A V8 isolate. Same OS process. | Reviewed plugins, and careless-but-not-hostile code |
| B — subprocess | a child node process | A separate address space. Same user. | Plugins you have not read |
| C — container | Docker, --network none | The kernel. | Anything executing arbitrary user code |
The tier is a property of the policy, and there is no fallback between tiers. If a plugin was assigned the container tier and Docker is unavailable, the call fails — it does not quietly run in a worker instead. Silently providing less isolation than an operator asked for is the exact bug this package exists to prevent.
How a plugin actually gets a tier today: defaultTierFor() (packages/sandbox/src/policy.ts)
is deliberately one if, not a decision tree — a plugin that declares the process capability (it
wants to execute code) goes to the container tier or does not run at all; every other plugin
starts at the worker tier. The subprocess tier is not chosen automatically by anything
today; the plugin host accepts an explicit tier override for an operator who wants to raise a
specific plugin to it. In practice, then, most plugins running right now are isolated at Tier A,
and any that ask to execute code are isolated at Tier C.
Tier A — worker isolation
A node:worker_threads thread with an environment the host built from nothing, V8 resourceLimits
on the heap and stack, a host-side wall clock that calls worker.terminate(), and a postMessage
boundary for every value that crosses.
It protects against:
- Ambient secrets.
process.envinside the worker is an object the host constructed, not a filtered copy of the API’s.OPENROUTER_API_KEYis absent, not redacted. Not evenPATHis passed.buildGuestEnv()throws if a caller ever allowlists a credential-shaped name, so the mistake surfaces at wiring time rather than in an incident. - Runaway CPU.
worker.terminate()interrupts a spinningwhile (true) {}. NoPromise.racecan do this — the loop never yields to the event loop, so a racing timer never fires. - Runaway memory. V8’s
resourceLimitskill the isolate at the heap cap instead of taking the API process down with an OOM. - Handing back live objects. The boundary is structured clone, so a returned function or live handle fails to cross rather than crossing.
- The casual reach for a dangerous builtin.
import('node:child_process')andrequire('fs')are both refused by the module firewall.
It does NOT protect against — and this matters:
A worker thread is not a security boundary. It shares the OS process, the address space and the file descriptor table with the API.
- Native addons. Anything reaching
process.dlopenexecutes machine code in the API’s own process and can read the API’s heap, including the secrets the environment scrub removed. The bootstrap deletesprocess.dlopenandprocess.binding, which removes the one-liner and not a determined attacker. - V8 and Node vulnerabilities. An isolate is a language boundary; a JIT bug crosses it.
- The module firewall itself. It intercepts ESM resolution and CJS
Module._load— two hooks inside the guest’s own thread, which the guest could in principle undo. It is a guardrail that makes accidents loud, not a wall.
Tier A is the right choice for a plugin whose source you have read. It is the wrong choice for anything else.
Tier B — subprocess isolation
A child node process: separate address space, an environment built from scratch, a cwd confined
to the plugin’s own directory, no inherited descriptors beyond stdio and one IPC channel,
execArgv: [] so the API’s flags do not extend into it, and a --max-old-space-size cap. On the
deadline it gets SIGTERM, then SIGKILL — and on Windows a taskkill /T, so a child that spawned
grandchildren does not outlive the kill.
This buys the one thing Tier A cannot: a native addon or a V8 exploit lands in a process holding no secrets, no database handle and no sockets, which can be killed without touching the API.
It does not buy a filesystem jail. The child runs as the same OS user with the same
permissions: it can read the repo, .env and novaterra.db if it asks the OS directly. The cwd is
a convention for the plugin’s own relative paths, not a boundary. It also has the host’s network
stack — the capability broker is what keeps a well-behaved plugin off the network, and a plugin
that opens its own socket bypasses the broker entirely. There is no cgroup, job object or pid cap,
so process-count limits are the caller’s job.
Tier C — container isolation
This tier does not reimplement containment; it delegates to the Docker path that already exists in
packages/skills/src/code/sandbox.ts and is documented flag-by-flag in docker/README.md:
--network none, --cap-drop=ALL, --security-opt=no-new-privileges, --read-only, a noexec
tmpfs at /tmp, memory/pids/CPU caps, and two independent timeouts. Duplicating that flag list
would create a second place for it to drift.
The runner is therefore injected rather than imported, which keeps @novaterra/sandbox below
@novaterra/skills in the dependency graph. That injected runner has since grown two non-Docker
backends of its own, and one of them is now the default — see
Where model-authored code runs now. It is a different
codebase from this package: this page’s three tiers are for plugins; those backends are for
code.execute and the scaffold build step.
A consequence worth stating plainly: because a plugin declaring the process capability is routed
to this tier, and this tier is Docker specifically, installing a plugin that wants to execute
code still requires Docker even though code.execute itself no longer does.
This is the only tier where “the plugin cannot read .env” is enforced by the kernel rather than
by convention. It still does not protect against container escape or a kernel exploit — a container
is a namespace, not a VM — nor against a plugin trashing the one project directory mounted at
/work.
The container tier depends on Docker actually being reachable, not just installed
Docker Desktop being installed doesn’t mean its engine is up. A common failure mode: the
com.docker.service Windows service is stopped while the \\.\pipe\docker_engine named pipe still
exists, so the CLI connects to a pipe nothing is serving and docker version hangs indefinitely
rather than failing fast. Whether Tier C has actually been verified on any given machine means
whether the image has been built and docker/sandbox/smoke.ps1 has actually been run against an
engine that is truly up — until that has happened here, every claim about Tier C on this page is a
claim about the flags in the source, not an observed result.
The eight-second probe in dockerAvailable() is what stops a wedged pipe from hanging the API: it
times out, reports Docker unavailable, and the container tier refuses rather than hanging. That path
is covered by tests/skills/sandbox-fail-closed.test.ts. Since code.execute now defaults to the
WASM tier, that wedged-pipe case is no longer probed at all on a default install — Docker is opt-in,
and this caution only applies to an install that turns Tier C, or NOVA_SANDBOX_RUNTIME=docker, on.
Where model-authored code runs now
code.execute and the scaffold build step choose a backend from NOVA_SANDBOX_RUNTIME, and the
default changed to wasm. Docker’s docker_data.vhdx had grown to 11.84 GB and never shrinks —
the sandbox image alone was about a gigabyte — on a machine that had hit zero free disk three times
in one night. Docker’s code path is untouched and still verified; it simply is not what happens
when nobody says otherwise.
NOVA_SANDBOX_RUNTIME | What runs the code |
|---|---|
wasm (default) | runtime/nova-wasm: wasmtime running the language runtime itself — QuickJS and CPython, compiled to wasm32-wasi — behind a hand-written wasi_snapshot_preview1 host. About 22 MB on disk, a 5–60 ms start, and identical behaviour on Windows and Linux |
nova | runtime/nova-sandbox: a daemonless Linux process supervisor — namespaces, cgroups v2, seccomp, Landlock. On Windows it runs inside WSL |
docker | the novaterra-sandbox image, with the flag list above |
auto | probe and pick |
What the WASM tier enforces is unusually easy to state, because the host is small enough to read:
its WASI implementation exposes 43 imports, no host filesystem path behind any of them, and no
sockets at all. The guest sees /work as an in-memory filesystem staged before the run and
flushed after it; the host performs no I/O while the guest is alive. path_link, path_symlink
and every sock_* call are recorded refusals that land in the run’s denied list rather than
silent errors. Environment variables are whatever the host passed, which is why environ_get
cannot leak an API key: the host never reads its own environment to build them.
The cost is real and is not hidden. No bash. No numpy, pandas or matplotlib. No npm. And
CPU-bound Python runs about four times slower than it does natively. If a project needs any of
those, Docker is still the tier that has them, and it is one environment variable away.
The rule that survives every change here: no tier silently degrades. A sandbox that cannot
isolate does not run the code. The host fallback — child_process on the machine itself, flagged
sandboxed: false — is refused outright unless an operator has explicitly set
NOVA_SANDBOX_ALLOW_LOCAL=1.
The capability broker
Tiers stop a plugin taking the machine. The broker decides what it may legitimately ask for.
A plugin never receives fs, net, fetch or process.env. It receives a context object whose
methods post a message to the host, where CapabilityBroker checks the request against the policy,
executes it, and records it. Arguments arrive as structured clones, so they are inert data by the
time the host sees them — there is no callback a guest can smuggle across to run on the host
thread.
Absence is the denial. An ungranted capability is missing from the context, not a stub that throws:
if (!ctx.fs) return { note: 'This plugin was not granted filesystem access.' };The policy is the intersection of what the manifest declares and what the owner granted, computed
once by buildPolicy() rather than checked at each call site — because the failure mode of
“check it everywhere” is “forget it somewhere”.
What each capability actually enforces
Filesystem. Every path is resolved against one host-chosen root and checked after symlink
resolution, because path.resolve alone is not enough: a symlink inside the root pointing at C:\
is a lexically-fine path that escapes. Absolute paths are refused outright, as are NUL bytes and
every spelling of ... Read-only grants refuse writes, and a byte ceiling applies per call.
Network. Two independent checks, both of which must pass before a socket opens, and both of
which run again on every redirect hop — a host that is public on hop 1 can redirect to
169.254.169.254 on hop 2:
- The allowlist: the host must match a pattern the plugin declared and the owner granted.
*.example.commatchesa.example.combut not bareexample.com, and neverexample.com.evil.net. - The SSRF guard:
assertPublicHost()frompackages/skills/src/web/http.ts— the same implementation the built-in web skills use, injected rather than copied, because there should be exactly one correct list of reserved IP ranges in this repo.
With no guard injected, network access is refused entirely. It does not degrade to an unguarded
fetch. Cookie, Authorization and the X-Forwarded-* family may not be set by a plugin, and a
per-run request ceiling stops a granted plugin becoming a traffic amplifier.
Storage. A quota’d key/value store scoped to one plugin and one being.
Every brokered call is written to an audit trail, allowed or refused, with the plugin id, the method and the target — never the payload. A capability system that cannot say what a plugin did is a permission dialog, not a control.
Five more host adapters, and what actually reaches a plugin
packages/sandbox/src/{integrations,llm,memory,schedule,signals}.ts implement five more capability
kinds a policy can describe, on top of filesystem/network/storage above, each pre-scoped to one
being server-side so there’s no beingId field for a guest to forge in the first place:
llm— routes through the real, budget-guardedOpenRouterClient; a plugin picks atier(cheap/standard/strong), never a specific model id, so it can’t route around the budget guard by naming an expensive model directly.memory,signals,schedule— each scoped to the granting being’s own data;signalsandschedulestrip any caller-suppliedaction/dueAtbefore writing, so a plugin can’t use them to forge a pre-approved skill invocation.integrations— effect-only (a plugin can ask an integration to act, never read back credentials);email.send/telegram.send/whatsapp.sendare refused pending a registry fix.
Correction (2026-09-07): this page used to say “every one of these is broker-mediated exactly
like filesystem and network above.” That overstated it, and the distinction is worth being precise
about. The broker in packages/sandbox/src/broker.ts does have a case for all eight capabilities —
fs, net, storage, llm, memory, signals, schedule and integrations. But the plugin
host, packages/skills/src/plugins/host.ts (which says so in its own header), wires a real backend
for only three of them: fs, net and storage. A sandboxed plugin does not receive llm,
memory, signals, schedule or integrations at all today — granting one of them to a plugin
buys it nothing, because there is no backend behind that broker case for the plugin host to call.
Absence is still the denial; it’s just that for these five, nothing has wired presence yet. What IS
wired — fs, net, storage — is genuinely broker-mediated, with the full audit trail described
above.
A module (see Plugins and extensibility) is a different runtime
with its own enforcement table, MODULE_CAPABILITY_ENFORCEMENT, where storage genuinely is
brokered — the two surfaces reuse the same capability names to mean different things on purpose.
Isolation and sandboxing already states this
distinction correctly; this page did not, until now.
What is still not isolated
Being straight about the edges, because a sandbox nobody attacked is a guess:
-
The built-in skills are unchanged. Everything in
packages/skillsstill runs in-process with full Node privileges. That is a deliberate choice — they ship in this repo and are reviewed like any other code — but it means the isolation story is about plugins, not about skills in general. -
Widgets, viewers, editors and themes are browser-side extensions. This package isolates server-side plugin code only; those four kinds run in the web app and need a separate answer. That answer shipped on 7 September 2026 — this bullet previously ended “It is not finished, and until it is, a plugin’s widget can be saved to a layout but not rendered”, which is no longer true. A module’s interface renders in an iframe with an opaque origin (
sandbox="allow-scripts", noallow-same-origin) and an injecteddefault-src 'none'; connect-src 'none'CSP, so it holds no session cookie and can open no connection of its own, with everything it does passing through one capability-checked server call that refuses anything outside what was granted.That boundary is demonstrated rather than asserted.
tests/modules/frame-isolation.chrome.test.tsdrives a real Chrome, reads thesandboxattribute out of the shipping component instead of retyping it, and shows the frame getting aSecurityErroron the cookie while the parent reads it fine, and afetchto/apithat never leaves the page at all. It carries a negative control: the same harness withallow-same-originadded shows the whole boundary collapse, so the passing assertions cannot be vacuous. It skips on a plain localpnpm testand says so; CI runs it on every push and then asserts how many tests ran, because a silently skipped security test looks exactly like a passing one. -
Generators are data, executed by the host’s agent runtime, so they inherit whatever that runtime can do rather than being confined here.
-
The container tier is unverified, as described above.
-
process.dlopenon Tier A remains the honest weak point. Assign anything you have not read to Tier B or C.
What an operator must do
- You no longer need Docker for
code.execute— the WASM tier is the default and ships with the repository. You do still need it to install a plugin that declares theprocesscapability, and to run anything that needsbashor the scientific Python stack. Without it, both of those refuse rather than degrading, which is the correct behaviour but means the feature is simply unavailable. - Never set
NOVA_SANDBOX_ALLOW_LOCAL=1in production. It is the single opt-in that lets model-written code run directly on the host, with the API’s filesystem access and network. It exists for a developer on a machine they are happy to hand over. In a real deployment it converts a fail-closed skill into remote code execution by design. - Grant the least a plugin asks for. The install screen shows the manifest’s
reasonstrings verbatim; a weather plugin asking fornetwork: *orprocessis telling you something. - Read the audit trail. It is the only record of what a plugin actually reached for, as opposed to what it declared.
Tests
packages/sandbox/test is written from the attacker’s side — 100 tests, 4 files, all passing
(pnpm --filter @novaterra/sandbox test), that each try a specific escape and assert it fails:
reading OPENROUTER_API_KEY out of a worker, importing child_process by both spellings and
through a CommonJS dependency, rebuilding require() out of node:module, spinning forever,
allocating without bound, returning a live function, escaping the filesystem root by four different
traversals, reaching 169.254.169.254 with network: * granted, and — the newest file,
host-adapters.test.ts — exercising the five capability kinds above from inside every tier.
Run them with pnpm --filter @novaterra/sandbox test. They are deliberately outside the root suite,
which collects only tests/**, because they spawn real threads and real processes.