Sandbox tiers and routing
For: cluster operators and plugin authors.
Sandboxes
Two kinds of work run in a sandbox where the node has one: harness sessions, such as a Claude Agent SDK agent with Bash, and every job an outside event starts, such as a flow a webhook starts, with its tools and the agents it runs.
The sandbox is the boundary for a harness session's commands. The tenant's command rules, on by default on every node with nothing installed, send a catastrophic command such as rm -rf ~ or curl ... | sh to a person before it runs, and refuse what the tenant refuses, but only a command the harness asks about, so they are a check beside the sandbox, never instead of it.
| Tier | Where | What a process can do |
|---|---|---|
| 0 | Every node | A working directory of its own and a minimal environment, with the node's user's permissions |
| 1 | Linux nodes with bubblewrap; macOS nodes (Seatbelt, passed on GitHub's macOS runner in October 2026; its profile's newer /Library and node-data rules have run only on Linux so far: see Sandbox on macOS) | Write only to its job's directories; read the system, its plugin and the node's managed Python, and nothing else of the node: not its data directory, keys, other plugins or other jobs; no network except through the node's egress proxy |
| 2 | Tools from container images (Sandbox tier 2): on Linux nodes with gVisor on rootful Docker or Podman; in microVMs (isolation: microvm, experimental) on Linux nodes with KVM; or in a remote sandbox on an E2B-compatible service a tenant admin allows (isolation: remote, tested against a stand-in of the service) | Its system calls go to a kernel of its own (gVisor's Sentry, or the microVM's or the remote sandbox's own kernel), not the node's; the node reports what it has and is labelled isolation.gvisor=ready or isolation.microvm=ready, never by hand |
Windows nodes run tier 0. Each probes for Windows 11's process security environment (PSEC), which Windows tier 1 runs in, and reports what it found. On Windows 11 25H2 the node binary's sandbox run keeps a command's files and network as tier 1 needs, with no administrator, and a node started with --sandbox psec runs a self-test of it and reports tier 1 when every check holds, but is still routed as tier 0 until harness sessions run in PSEC (Sandbox on Windows). Windows Server stays at tier 0.
How tier 1 works
oz0-node sandbox check after installation: resolve any bubblewrap advisory finding and confirm Protected paths: on before relying on those protections. Upgrade from a trusted package source or use a node with a fixed version.On Linux, each session or job's tool server starts as oz0-node sandbox run, which runs it under bubblewrap in new namespaces: its own mounts (the system read-only, its directories writable, a private /tmp), its own process IDs and an empty network. Inside, the node binary listens on loopback and forwards to Unix sockets outside: one to the egress proxy, one to the node's LLM proxy. A process that ignores HTTP_PROXY has no other way out.
On macOS a Seatbelt profile allows the same files, only the proxy's port and no Unix socket, and the node reads Seatbelt's denials back from the unified log.
The current macOS profile and installed-plugin sandboxes have not completed native validation. Read the macOS validation and isolation limits before choosing those nodes for sandboxed work.
How sandbox boundaries are tested
An escape matrix (pkg/sandbox/testdata/egress-cases.json) holds a row for every sandbox mechanism a node can report and every platform the node ships for; a mechanism or platform without one fails the tests.
The attempts test whether a sandboxed process can:
- Read a flag file beside the working directory through absolute or relative paths,
.., doubled slashes, links, hard links or/proc. - Write the working directory's protected paths.
- Reach a canary on
127.0.0.1,::1or the node's own address over TCP, UDP (including DNS), or a Unix socket. - Reach that canary through the egress proxy using other loopback spellings, IPv6, redirects, names resolved over UDP or TCP, or names that initially resolve elsewhere; or reach metadata services.
- Open a raw socket or make the proxy resolve a name it refuses.
- Signal or read another process belonging to the node's user.
- Leave a process running after the sandbox ends, read an inherited descriptor, or push keystrokes into the node's terminal.
Each attempt runs unconfined first and must reach its target there, or it counts as untested on that machine, never as held; a raw socket, which an ordinary user cannot open unconfined either, is shown instead in a namespace where one opens.
go test ./pkg/sandbox/ runs it under bubblewrap, rootless gVisor and Seatbelt where the machine has them, and the egress proxy's part on every platform; make e2e-sandbox and scripts/test-macos-sandbox.sh run it through the node binary.
Windows' PSEC, gVisor under a container engine, microVMs and remote sandboxes have their rows but are untested by it so far.
What tier 1 blocks
On Linux the sandbox does more than hide the node's files. Run oz0-node sandbox check to see it on a machine. Each sandboxed process:
- Cannot create a user namespace of its own. Where bubblewrap supports it (version 0.8.0 and later) the sandbox is started with
--unshare-user --disable-userns; everywhere a seccomp filter denies the namespace-creating calls besides. Without this a process couldunshare -Urninto a child namespace with a full capability set. - Holds no Linux capabilities (
--cap-drop ALL), even when the node runs as root. - Is held by a seccomp filter that refuses
io_uring,bpf, the kernel keyrings (keyctl,add_key,request_key),ptraceandprocess_vm_readv/writev,pidfd_getfd,perf_event_open,userfaultfd, the mount family, the kernel-module andkexeccalls,unshareandsetns(with any flags), andclonewith a namespace flag.clone3, whose flags a filter cannot read, returns "not implemented", so the C library falls back toclone. A call on a foreign architecture, or through the x32 ABI, kills the process.personality(ADDR_NO_RANDOMIZE)is allowed so sanitiser builds (go test -race) run. - Cannot reach its harness's channel. A session's streams to the runtime are socket pairs, which no process can open through
/proc, and a session's/procis read-only, so no process can write another's memory through it. The threat model says what this boundary holds and what it does not. - Starts from a cleared environment: only the variables the process needs are set, never the node's own. The environment values are passed to bubblewrap through a file descriptor, so they are not visible in the process list, and that descriptor is closed before the process starts, so nothing in the sandbox can read it back. A bubblewrap older than 0.5.0 (RHEL 8, Ubuntu 20.04) has no
--clearenv; the node then starts bubblewrap with an empty environment, with the same result. - Never binds a path that lies in, or reaches its target through, a directory the sandbox can write. The node resolves each path one component at a time and refuses the bind when the path, any symbolic link on the way or the target is inside the sandbox's working directory or another writable directory, at either spelling of that directory.
That is how bubblewrap's CVE-2026-87766 is reached (a symlink in an agent-writable directory redirecting a mount onto the host), and also how an agent could re-target a link to change what a later sandbox reads. A bubblewrap older than 0.12.0 is still vulnerable in general, so install a patched one where you can;oz0-node sandbox checksays when yours is older.
Two kinds of agent need part of this relaxed. A harness that starts its own sandbox (Codex's bubblewrap, srt in Claude Code, Chromium under Puppeteer) must run with that sandbox turned off inside tier 1, since nested user namespaces are blocked.
An agent that needs a debugger (gdb, strace, py-spy, a LeakSanitizer build) sets sandbox: { ptrace: true } in its definition, which widens the filter to allow ptrace for that agent's sessions only. Such a session runs only where kernel.yama.ptrace_scope is 1 or more, which keeps tracing to a process's own descendants; elsewhere its tools could trace their harness, and the session is refused.
Which tier runs what
- A node reports its tier when it connects;
oz0-node sandbox checkshows it on the machine, andnode show, the Nodes page and the catalog show it everywhere else.oz0-node run --sandbox offruns tier 0. A node at tier 0, or with a bubblewrap that has a known advisory, is a health finding with its fix. On Ubuntu 24.04 and later, tier 1 needs Ubuntu's own AppArmor profile for bwrap, whichsudo oz0-node sandbox setup(and the install scripts, as root) loads; never an unconfined one (Sandbox tier 1 on Linux). - On a tier-1 node every harness session runs in the sandbox. A job an outside event started runs its tools in a tool server of its own, in the sandbox.
Shell and write sessions
Shell and write sessions run on tier 1, whoever started the job. A harness session whose agent can run a shell or write files (Bash, Write, Edit, an ACP agent's execute, or a harness the platform cannot check) runs only on a tier-1 node, in a job a person or an app started as much as in one a webhook started: the machine that acts on what a model answers is the one held to the sandbox, and a model that misbehaves on a trigger does not care who started the job.
Such a session waits on its pool's floor queue (floor.<queue>), which tier-1 nodes poll; when none takes it within 10 minutes the job fails with no node with sandbox tier 1 took the session of ... and names the fixes, a node with tier 1 or a node's opt-out.
It never falls back to a node without a sandbox. A dev server lets every node take them, and says so.
Per-node opt-outs
The per-node opt-out. Where a node can have no sandbox (Windows Server, a Mac whose Seatbelt sandbox does not start (tier 1 on macOS passed on GitHub's macOS runner in October 2026; its profile's newer /Library and node-data rules have run only on Linux so far, see Sandbox on macOS), a Kubernetes pod without the tier-1 configuration, Linux without bubblewrap), a tenant admin may let that one node run such sessions at tier 0, with a reason: oz0 node sandbox-optout <node> --reason ....
It is a governance change (with four eyes on, another admin approves it; ending it acts at once), audited as tenant.sandbox_optout.
While the node holds it, the node is a health finding, and every job that ran a shell or write session there shows tier 0 and that node on its page, in oz0 job get and in the audit log (run.sandbox.tier0), so the lower floor is never silent.
What it costs: on that node the session's shell has the node user's files, network and rights, as any program the user runs.
Jobs an outside event started never run there; they still need a real tier-1 node. There is no tenant-wide opt-out.
Tier 1 bounds what the shell can reach; it does not make a model's answers trustworthy, so a backdoor in the weights is still a backdoor, only one with less to reach.
Jobs started by outside events
Jobs from outside run only on tier-1 nodes. Work an outside event starts (a webhook's delivery, an event, a routine's webhook or event trigger, a chat message) runs only where a node of the tenant with sandbox tier 1 runs it: the edge marks the job, and only tier-1 nodes may take its start, so the flow's home, where its tool steps run, is a tier-1 node, and so are the homes and sessions of the agents it runs and of its children.
An event source's function, which reads each delivery, runs on a tier-1 node too.
Work that no tier-1 node takes within 10 minutes fails with no node with sandbox tier 1 took ..., instead of waiting without a word.
Where the tenant has no tier-1 node for the work, such as one with only Windows nodes, the edge refuses the start with the reason and the fix (start a node with sandbox tier 1): it never runs at tier 0. A node a tenant admin opted out of the sandbox, and any node of a dev server, runs tier-0 work only for jobs started from inside.
Webhooks and shell tools
Webhooks and shells. A webhook's body is text from outside. A harness agent with a shell or write tools (Bash, Write, Edit, or a harness the platform cannot check) may handle it only in tier 1: its sessions in such jobs go to tier-1 nodes and nowhere else, and plugin install refuses such a flow unless a tier-1 node of the tenant has the labels the agent requires. It also warns about other agents a webhook flow reaches that no tier-1 node can run.
Those sessions also get no plugin secrets, so the process that outside text drives never holds both a shell and a write token.
Per run, the job is tainted from its start, and so is every job that later reads outside text (an open-world result, an event, a tainted child's or colleague's answer): the built-in taint-default rule then makes each call the job's workflow decides that can send data out wait for a person, in that job and in the children it starts, and the card and the trace say why.
That covers a harness session's MCP tools, the servers an imported plugin's install lets its sessions run included, not its own built-ins: a session's shell or web fetch runs inside the session and waits for a person only when a pre_tool_call hook or the agent's harness.requires_approval names it, or, for a shell command, when a command rule asks (they read the command, not the job's taint), so name them there for such flows.
A quarantine child reads outside text and answers its parent only in a bounded shape. This limits what injected text can do; it does not stop a model from misusing what it read. Give such flows tools that only do what the flow needs, behind hooks and approvals.
- Each job shows the tier it ran in, the lowest of its sessions and tool calls, and whether an outside event started it.
Unvetted models
An unvetted model runs on tier 1 only. A job whose agent may run on a model the tenant has not vetted (a local model, or one behind an endpoint of the tenant's own) is tainted from its start and runs on a tier-1 node only, its start and every session, whoever started it, and so does every job it starts (a flow's step, a delegate, a best-of candidate, a reviewer).
It never falls through to tier 0 without a word: with no node that can take it, it fails with the reason and the fix (add a tier-1 node, opt a node out, or vet the model).
The model itself is never refused. A tier-1 node polls the queues of the pools whose agents may run on such a model.
- A tier-1 node polls a sandbox queue only for the plugins' pools whose work may need it: a pool with an agent that has a shell or write tools, a flow with a trigger, an agent or flow a routine or a chat channel starts, a plugin with an event source of its own, and every agent those reach through flow steps and delegation. Other pools have none, which halves the queues and the runtime's workers a tier-1 node has for them.
A backdoored model
A model's weights can be edited, an abliteration that strips its refusals, a fine-tune, a merged adapter, so that it behaves normally until a trigger it was given (a customer name, a ticket ID, a date) appears in the prompt, and then drives the session to fetch a script and run it unread, read secrets from the working directory and send them out.
No runtime detects a backdoor in the weights, and the platform never claims to.
What it can do is bound what the session does with it, and here the sandbox is the boundary: at tier 1 the fetch and the exfiltration are refused by the egress proxy and audited, the session reads only its job's directories and holds no provider key (and, in a job an outside event started, no plugin secret either, as the Webhooks and shells note above describes), and a kill switch stops the agent within seconds. A tier-0 node has none of this, so a shell or write session there is a gap (tier-0 boundaries).
make e2e-malicious-models exercises such a model against the run's available attack paths:
- Every catalog function that changes something or sends data out.
- The built-ins offered by its agents and harness sessions, and the harness's own tools.
- A plugin with an egress list.
- Every edge route a node may call.
It runs on every sandbox tier reported by the test nodes. A case fails unless it is refused, held for a person or contained as the platform declares. The suite's own tests fail when a function, tier or platform is added without a case.
Pinning a model by its digest (sha256) pins its integrity, not its trust: the digest proves the weights are the ones you pinned, nothing more. Nothing in the weights is vetted by a digest. A coding model, backdoored or not, can still write a subtle flaw into code a person later merges, the approvals and the evals help a person catch it, but no runtime stops it. Run a shell or write session in tier 1, review what a model's session changes before it merges, and treat a model from an unvetted source as untrusted.