Skip to content

ADR-0029: Execution Isolation Tiers

  • Status: Accepted. Implemented in lib/isolation.cjs. Default tier is direct, which is the status quo; the container tier is opt-in.
  • Date: 2026-07-30
  • Supersedes: None
  • Related: ADR-0008 (worktree isolation — a different kind of isolation, see below), ADR-0001 (why the runtime probe lives in bin/), ADR-0020 (reviews content; this bounds blast radius), ADR-0021 (the spawns being wrapped)

Context and Problem Statement

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, and has never had an isolation story of its own. In practice an executor agent holds whatever the operator's account holds: the whole home directory, every credential file, the network.

That was tolerable while every plan was written and reviewed by the operator running it. It stops being tolerable as soon as a plan is generated, shared through a repository, or produced by a different model than the one executing it.

There is also a naming hazard that this ADR exists partly to correct. ADR-0008 is called "worktree isolation", and it is real — but it isolates working-tree versions so parallel slices do not collide. A git worktree grants exactly the same filesystem rights as the parent checkout. Reading ADR-0008 as a sandbox is a category error, and the word "isolation" invites it.

Decision Drivers

  • Say what is actually true. An honest description of direct is worth more than a partial mitigation, because an operator who knows they have no isolation makes different choices.
  • Do not break existing installs. Isolation cannot become mandatory.
  • Never silently downgrade. This is the property everything else follows from.
  • [ADR-0002] Zero runtime dependencies. No container SDK; shell out to a runtime the operator already has, or refuse.
  • [ADR-0001] No daemon — and lib/ never spawns (D-14).
  • Claim no more than is delivered. A tier that oversells itself invites exactly the trust it cannot support.

Considered Options

  • A: Do nothing, document the risk in prose. The risk is real and prose alone does not let an operator choose a stronger posture.
  • B: Rely on the host CLI's own permission prompts. Varies per host across fourteen runtimes, cannot be asserted, and is not something nubos-pilot can reason about.
  • C: Implement a bespoke sandbox (seccomp/namespaces). A large, platform-specific security surface that nubos-pilot is not positioned to maintain correctly.
  • D: A named tier model with an opt-in container tier that shells out to an existing runtime. Chosen.

Decision Outcome

Chosen: Option D. Three tiers, named so the weakest cannot be mistaken for protection:

TierIsolates versionsIsolates rightsMeaning
directnonoNo isolation from nubos-pilot. The agent has whatever the host CLI grants — normally your entire account.
worktreeyesnoVersion isolation only (ADR-0008). Parallel slices cannot corrupt each other. Rights are unchanged from direct.
containeryesyesRights isolation. Project directory mounted, nothing else, no network by default.

direct is the default because it is the status quo, not because it is safe, and it is named direct rather than none so it cannot be read as "no isolation needed".

workflow.worktree_isolation stays authoritative for its own concern and raises the floor to worktree. It can raise the tier but never lower it, so a project that opted into containers does not lose them by leaving the older flag off.

Tier guarantees are data, not prose

describeTier() returns structured fields — isolates_versions, isolates_rights, network_restricted, summary, use_when, does_not_protect_against. The CLI, the docs and doctor all read the same object, because three prose copies of a security boundary is how the three come to disagree.

Every tier, including container, states what it does not protect against: container escape, anything reachable through a mount or network the operator enabled, and the fact that isolation bounds blast radius without reviewing anything.

A configured tier is never silently downgraded

When isolation.tier is container and no container runtime is usable, every verb that would claim isolation refuses.

This is the core decision. An operator who asked for isolation and quietly got direct is strictly worse off than one who got an error: they will execute untrusted plans believing they are contained. To accept direct, you set it explicitly.

Container hardening is stated, not inherited

--network none, --cap-drop ALL, --security-opt no-new-privileges, --read-only root with a tmpfs /tmp, project mounted read-write at the workdir. All explicit, because a daemon default that changes between Docker versions would silently change the sandbox. The default image is pinned for the same reason — a moving tag changes the sandbox contents with no change to this repository.

Environment variables are forwarded by name only (-e NAME, never -e NAME=value). The value then travels through the Docker API instead of the process table, where ps would expose every API key on a shared machine.

Mount validation is strict

The tier's entire claim is "the project and nothing else", so:

  • a writable mount outside the project is refused — it re-opens exactly the hole the tier was adopted to close, while the operator still believes they are contained;
  • a mount of 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 (/project-secrets against /project) counts as outside.

Consequences

Good, because:

  • The security posture is now stateable and choosable rather than implicit.
  • The ADR-0008 category error is corrected in machine-readable form: worktree reports isolates_rights: false.
  • Executing a generated or third-party plan has a supported answer.

Bad, because:

  • The container tier requires Docker, which nubos-pilot does not install and cannot assume. On a machine without it, the tier is unavailable and says so rather than degrading.
  • Running an agent in a container changes what the agent can do — no network means no WebFetch, and the project mount means tools outside it are absent. Some plans will not run under container without extra read-only mounts.
  • direct remains the default, so the common case is unchanged. This ADR makes the risk visible; it does not remove it.
  • Wrapping adds container start-up latency per spawn.

An architectural violation this exposed

The first implementation put the container-runtime probe in lib/isolation.cjs, and the surface audit failed: lib/ must never import child_process (ADR-0001, D-14; only lib/git.cjs is exempt).

Rather than widen the whitelist, the probe moved to bin/np-tools/isolation.cjs and its result is injected into assertTierAvailable, which now refuses to run without one. The constraint improved the design: lib/isolation.cjs cannot silently acquire the fact it gates on, so the caller must state where that fact came from. Defaulting the probe either way was rejected — available would make the gate a rubber stamp, and unavailable would send a caller who forgot the probe off debugging a Docker install that is fine.

More Information

  • Implementation: lib/isolation.cjs (pure), probe + CLI in bin/np-tools/isolation.cjs (verbs status | describe | probe | wrap).
  • Wiring: workflows/execute-phase.md Step 0a asserts the tier before any executor spawn.
  • Config: isolation.tier, isolation.container.{image, network, user, memory, cpus, mounts}.
  • Tier model credit: the direct / container-with-volume-mount / source split is taken from OpenHands' documented deployment options.