Skip to content

Sandboxing

Novaterra runs two kinds of code it did not write. The first is model-authored codecode.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.

TierMechanismBoundaryUse for
A — workernode:worker_threadsA V8 isolate. Same OS process.Reviewed plugins, and careless-but-not-hostile code
B — subprocessa child node processA separate address space. Same user.Plugins you have not read
C — containerDocker, --network noneThe 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.env inside the worker is an object the host constructed, not a filtered copy of the API’s. OPENROUTER_API_KEY is absent, not redacted. Not even PATH is 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 spinning while (true) {}. No Promise.race can do this — the loop never yields to the event loop, so a racing timer never fires.
  • Runaway memory. V8’s resourceLimits kill 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') and require('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.dlopen executes machine code in the API’s own process and can read the API’s heap, including the secrets the environment scrub removed. The bootstrap deletes process.dlopen and process.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_RUNTIMEWhat 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
novaruntime/nova-sandbox: a daemonless Linux process supervisor — namespaces, cgroups v2, seccomp, Landlock. On Windows it runs inside WSL
dockerthe novaterra-sandbox image, with the flag list above
autoprobe 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:

  1. The allowlist: the host must match a pattern the plugin declared and the owner granted. *.example.com matches a.example.com but not bare example.com, and never example.com.evil.net.
  2. The SSRF guard: assertPublicHost() from packages/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-guarded OpenRouterClient; a plugin picks a tier (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; signals and schedule strip any caller-supplied action/dueAt before 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.send are 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/skills still 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", no allow-same-origin) and an injected default-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.ts drives a real Chrome, reads the sandbox attribute out of the shipping component instead of retyping it, and shows the frame getting a SecurityError on the cookie while the parent reads it fine, and a fetch to /api that never leaves the page at all. It carries a negative control: the same harness with allow-same-origin added shows the whole boundary collapse, so the passing assertions cannot be vacuous. It skips on a plain local pnpm test and 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.dlopen on 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 the process capability, and to run anything that needs bash or 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=1 in 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 reason strings verbatim; a weather plugin asking for network: * or process is 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.