Execution Isolation Tiers
Decision record: ADR-0029.
Start here: what you have by default
nubos-pilot installs a payload into a host CLI and runs as short-lived node invocations inside it. It therefore inherits that host's permissions. By default, an executor agent holds whatever your user account holds — your whole home directory, every credential file you can read, and the network.
That is the direct tier. It is the default because it is the status quo, not because it is safe.
Check what you are actually running with:
node .nubos-pilot/bin/np-tools.cjs isolation statusThe three tiers
| Tier | Isolates versions | Isolates rights | What it means |
|---|---|---|---|
direct | no | no | No isolation from nubos-pilot. The agent has whatever the host CLI grants. |
worktree | yes | no | Version isolation only. Parallel slices cannot corrupt each other's working tree. Rights are unchanged from direct. |
container | yes | yes | Rights isolation. Project directory mounted, nothing else, no network by default. |
A worktree is not a sandbox
This is the most common misreading, and it is worth being blunt about.
ADR-0008 is called "worktree isolation" and it does exactly what it says — but what it isolates is working-tree versions, so that two slices running in parallel do not overwrite each other. A git worktree grants precisely the same filesystem rights as the parent checkout.
So worktree is a correctness measure, not a security one. isolation describe worktree reports isolates_rights: false, and every consumer — CLI, docs, doctor — reads that same field rather than a separate prose copy.
When to reach for container
Use it when you are executing a plan you did not write or review: a generated plan, a plan from a shared repository, or any run you would not hand your shell to.
{
"isolation": {
"tier": "container",
"container": {
"image": "node:22-bookworm-slim",
"network": "none",
"user": null,
"memory": null,
"cpus": null,
"mounts": []
}
}
}workflow.worktree_isolation still works and raises the floor to worktree. It can raise the tier but never lower it, so opting into containers is not undone by leaving the older flag off.
What the container tier actually enforces
Every flag is stated explicitly rather than inherited from daemon defaults, because a default that changes between Docker versions would silently change your sandbox:
--network none— no network unless you opt in. An agent that can reach the internet can exfiltrate the repository it was given.--cap-drop ALL,--security-opt no-new-privileges--read-onlyroot with atmpfs /tmp; only the project mount is writable, because editing the project is the agent's job- the image tag is pinned, not
latest— a moving tag changes the sandbox contents with no change to your config - environment variables are forwarded by name only (
-e NAME), so a value travels through the Docker API instead of the process table wherepswould expose it
What it does not protect against
Stated plainly, because a tier that claims total protection invites exactly the trust it cannot earn:
- a container escape — this is a container, not a VM
- anything reachable through a mount or a network you enabled
- the plan committing bad code. Isolation bounds blast radius; it does not review anything. That is ADR-0020's job.
Extra mounts are validated, not trusted
The tier's whole claim is "the project and nothing else", so:
- a writable mount outside the project is refused — it re-opens the exact hole the tier was adopted to close, while you still believe you are contained
- a host control surface (
/var/run/docker.sock,/proc,/sys,/dev) is refused even read-only — read-only does not contain the docker socket, which is a root shell either way - a prefix-only match counts as outside:
/project-secretsis not inside/project - symlinks are resolved before either check. A link inside the project pointing at
$HOMEis a path that looks contained and mounts something else, because Docker binds what the link points to. Both rules are applied to the resolved target, and a dangling link is followed by hand so a source whose target does not exist yet cannot slip through either.
A read-only mount outside the project is allowed, which is how you give a container a toolchain it needs.
The tier is never silently downgraded
If isolation.tier is container and no container runtime is usable, every verb that would claim isolation refuses:
isolation-container-unavailable: isolation.tier is "container" but no container runtime is
usable (runtime-not-installed: docker is not on PATH). Refusing to run: silently falling
back to `direct` would hand the plan your full account rights while you believe it is
contained.This is deliberate and is the property the whole design rests on. An operator who asked for isolation and quietly got direct is strictly worse off than one who got an error, because they will execute untrusted plans believing they are contained. To accept direct, set it explicitly.
/np:execute-phase asserts the tier in Step 0a, before any executor spawn.
Commands
# what am I running with, and what does it guarantee?
np-tools.cjs isolation status [--json]
# explain one tier
np-tools.cjs isolation describe container
# is a container runtime usable?
np-tools.cjs isolation probe
# what would the wrapped invocation be?
np-tools.cjs isolation wrap --env ANTHROPIC_API_KEY -- node --versionwrap prints the command rather than running it, so the caller keeps stdio, timeout and env handling. On a non-container tier it reports wrapped: false explicitly instead of emitting a bare command — a bare command would be indistinguishable from a wrapped one, which is how a caller ends up believing an unwrapped spawn is contained.
Trade-offs to expect
Running an agent inside a container changes what the agent can do. No network means no WebFetch. The project mount means tools outside the project are absent. Some plans will not run under container without additional read-only mounts, and every spawn pays container start-up latency.
That cost is the point: the tier is worth paying for when you do not trust the plan, and direct remains the right answer when you wrote it yourself.
