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.
loadPluginModuleonce didawait import(entry)in the host process, which meant a plugin’s module body — the code that runs before anything calls itsrun— evaluated with this process’s full privileges. Sandboxing onlyrunwould have been theatre: by the timerunexisted, 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 declaringworld-actingrefuses to run withoutapproved: true, exactly like the six built-in world-acting names. Manifest validity and API version.NOW ALSO ENFORCED (it was not, before) —
process.envis absent inside the guest, not redacted, for the module body too.node:fs,node:child_process,node:net,node:httpare refused by the module firewall. CPU and memory are capped, and a spinning loop is terminated. A different realm means a plugin cannot patchArray.prototypeand 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/llmadapters 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
reasonstrings 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
processcapability 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,integrationsorllmgrant is still a promise, not yet a channel — the capability is checked and approved, but no adapter delivers it to a sandboxed plugin today.