Skip to content

Isolation and sandboxing

This changed on the night this documentation was written, so read it as a snapshot of a real, working mechanism rather than a plan: packages/skills/src/plugins/loader.ts and host.ts now route a plugin’s code through @novaterra/sandbox instead of importing it into the API process. The account below is sourced directly from those two files’ own comments, because they say this at unusual length precisely because getting it wrong would be dangerous.

The rule the loader states as a fact, not a hope

Quoting loader.ts directly:

NOTHING IN THIS FILE EXECUTES PLUGIN CODE. That is the single most important property here and it is worth stating as a rule rather than a hope: this module parses JSON and does path arithmetic. There is no import() of anything a plugin wrote, anywhere in it.

It did not used to be true. loadPluginModule once did await import(entry) in the host process, which meant a plugin’s module body — the code that runs before anything calls its run — evaluated with this process’s full privileges. Sandboxing only run would have been theatre: by the time run existed, the damage was done.

The fix has two halves: the host resolves a plugin’s main to an absolute path and never imports it (resolvePluginEntry), and PluginHost hands that path to runSandboxed() (@novaterra/sandbox), which imports it inside the guest — a worker thread with a scrubbed process.env, a module firewall over node:fs/node:child_process, V8 heap and stack caps, and a hard terminate() deadline (or a container, for a plugin that declared process). Registering a plugin’s skills still only needs the manifest — JSON, already parsed without executing anything — so the grant can be decided, the namespace claimed and the approval gate applied with no plugin code having run at any point, whether the install succeeds or is refused.

PluginHost.install() enforces this structurally, not by convention: it throws if a plugin from any source other than builtin (in-tree code the repo already reviews) arrives without an entryPath — “Use installPluginFromDirectory … so the module is imported inside the sandbox instead.” There is no code path left that installs a directory-, Market-, or tarball-sourced plugin’s skill without going through the sandbox.

What’s enforced on every call

Quoting loader.ts’s own summary:

STILL ENFORCED, unchanged from before — Capability grant, checked twice: against the manifest before anything is resolved, and again per skill inside the registry. Namespacing — every plugin name is <namespace>.<extensionId>, checked against the reserved list and every installed plugin. The approval gate — a plugin skill declaring world-acting refuses to run without approved: true, exactly like the six built-in world-acting names. Manifest validity and API version.

NOW ALSO ENFORCED (it was not, before)process.env is absent inside the guest, not redacted, for the module body too. node:fs, node:child_process, node:net, node:http are refused by the module firewall. CPU and memory are capped, and a spinning loop is terminated. A different realm means a plugin cannot patch Array.prototype and reach the host’s copy. Network is routed through the capability broker, allowlisted per the manifest’s host list and re-checked against the real SSRF guard on every redirect hop.

That last point matters specifically for plugins, not just for the sandbox package in the abstract: sandboxedRun() (packages/skills/src/plugins/host.ts) wires assertPublicHost — the same SSRF guard web.fetch and web.search use — into every plugin’s network policy, and code.execute’s own container runner (runCode) into the container tier, so a plugin’s isolation reuses the same, single implementation of each guard rather than a second copy that could drift.

How a tier is actually picked

defaultTierFor() (packages/sandbox/src/policy.ts) is one if: a plugin that declares the process capability goes to the container tier (Docker, --network none) or refuses to run; every other plugin starts at the worker tier (a node:worker_threads thread — same OS process, separate V8 isolate, refused module access to fs/child_process). The subprocess tier from Sandboxing is available but not chosen automatically by anything today; an operator can raise a specific plugin to it by hand. The policy is rebuilt fresh from the manifest and the current grant on every single call, not cached at install time — a capability the owner revokes takes effect on the plugin’s very next invocation, not at the next restart.

One practical consequence, since code.execute moved off Docker by default: the plugin container tier is still Docker specifically, so installing a plugin that declares process still requires Docker, even on a machine where the built-in code sandbox no longer does. It refuses rather than dropping to a weaker tier, which is correct and also means the plugin simply will not run.

The full technical account of what each tier protects against and what it doesn’t — the worker tier’s process.dlopen weak point, the subprocess tier’s lack of a filesystem jail, the container tier’s unverified status on the owner’s own machine — lives in Sandboxing and applies here without change; this page only covers what is specific to a plugin call.

What’s still not covered

Quoting loader.ts once more, because the honest gaps matter as much as the real progress:

STILL NOT ENFORCED — A worker is not a security boundary: a native addon or a V8 bug crosses it. Raise a plugin to the subprocess or container tier for code nobody has read. The memory / signals / schedule / integrations / llm adapters have no brokered equivalent, so a sandboxed plugin simply does not receive them today.

That last sentence is worth sitting with: a plugin declaring memory: read-write or llm still passes the capability grant check (the owner sees and approves the request), but there is no message-passing adapter yet that would actually let a sandboxed plugin call into the Twin’s memory or spend the being’s inference budget — the context it receives for those capabilities is simply absent, the same “absence is the denial” rule the broker applies everywhere else, just for a different reason (nothing built the bridge yet, rather than the owner having said no).

The container tier carries the same caveat as everywhere else in this repository: it is unverified on the owner’s own machine. Docker Desktop’s engine was not running while this was written, so a plugin that declares process and is routed to Tier C has never actually been exercised end to end here — only the worker-tier path (the default for everything else) has.

The Rust prototype (runtime/)

A separate, independent effort exists at runtime/ — a Cargo workspace (nova-sandbox, nova-wasm, nova-probe) aimed at kernel-enforced isolation without a container daemon, in the spirit of bubblewrap/nsjail/youki. Its own top-of-file documentation is explicit that it is “not a container runtime” and has “no image format, no layer store, no registry client and no daemon” — it goes at the kernel’s own primitives directly, gated behind #[cfg(target_os = "linux")]. There is no README in runtime/, and nothing in either the Node API or @novaterra/sandbox calls into it — it exists in the repository as a prototype, not as something the plugin path above uses. Treat any claim about what it protects against as unverified until it has one.

What an owner should actually do

  • Read the manifest’s reason strings before installing anything. They’re the only place a plugin explains, in its own author’s words, why it wants what it’s asking for.
  • A process capability means the container tier or nothing — the plugin host will not quietly drop it to a worker. If Docker is unavailable, that plugin’s calls fail closed.
  • Everything else runs at the worker tier by default. That’s real isolation against ambient secrets, runaway CPU/memory, and the casual require('fs') — and it is explicitly not a defence against a determined attacker with a native addon or a V8 bug. Raise anything you haven’t read to a stronger tier by hand.
  • A memory, signals, schedule, integrations or llm grant is still a promise, not yet a channel — the capability is checked and approved, but no adapter delivers it to a sandboxed plugin today.