Skip to content

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:

bash
node .nubos-pilot/bin/np-tools.cjs isolation status

The three tiers

TierIsolates versionsIsolates rightsWhat it means
directnonoNo isolation from nubos-pilot. The agent has whatever the host CLI grants.
worktreeyesnoVersion isolation only. Parallel slices cannot corrupt each other's working tree. Rights are unchanged from direct.
containeryesyesRights 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.

json
{
  "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-only root with a tmpfs /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 where ps would 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-secrets is not inside /project
  • symlinks are resolved before either check. A link inside the project pointing at $HOME is 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

bash
# 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 --version

wrap 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.