Two-Zone Isolation
A loop that runs while you sleep has a blast radius. The question is not whether the agent misbehaves - prompt input is untrusted, and the code it produces is untrusted. The question is what the worst case can reach. Two-zone isolation is the SPEC section 7 contract that bounds it.
The single-container contradiction
Section titled “The single-container contradiction”The intuitive isolation move is one hardened container: docker run --network none, bind-mount ~/.claude:ro for the key, done. That box cannot exist.
A cloud agent must reach its model endpoint - claude -p needs api.anthropic.com:443; a local worker needs http://localhost:1234. --network none has no NIC and no DNS. So the single container has no safe setting:
- Network off -> the first agent call aborts. Under a
set -eshell that death is silent, not a computed halt reason. - Network on -> the agent now has a route to the entire internet, and the bind-mounted long-lived key is readable. A prompt-injected agent exfiltrates it.
One container cannot both run a cloud agent (needs egress) and isolate untrusted code (needs no egress). Those are two different trust boundaries around two different untrusted inputs. They must be two zones.
Two zones, one shared clone
Section titled “Two zones, one shared clone”The contract splits execution into an agent zone and an exec zone. They share exactly one thing: a /work volume that is a detached clone of the repository - never the host repo.
+------------------------------------------+ | loopexec runtime (host) | | orchestrator | receipts | heartbeat | | mints+revokes creds | doctor | watchdog | +-------+-------------------------+---------+ v v +---------------------------+ +------------------------------+ | AGENT ZONE | | EXEC ZONE | | reasoning / produce diff | | check | build | test | | untrusted: prompt input | | untrusted: CODE execution | | net: EGRESS ALLOWLIST | | net: NONE (--network none) | | -> api.anthropic.com:443| | hermetic: frozen clock/seed | | via auditing proxy | | /TZ/locale, :0 ports | | creds: per-run minted, | | creds: NONE | | TTL<=loop, spend-capped | | ephemeral: reset each run | | fs: RW->/work, RO else | | fs: RW->/work, RO root, tmpfs | | NO $HOME | NO ~/.claude | | | +-------------+-------------+ +--------------+---------------+ +---------------+--------------+ shared /work volume ONLY (a detached CLONE, not the host repo)Each zone is sized to its own untrusted input. The agent zone trusts neither the prompt nor the model output, but it needs a narrow route to the model. The exec zone runs the untrusted code - the check, the build, the test - and needs no route anywhere.
Agent zone
Section titled “Agent zone”The reasoning surface. It produces the diff and is the only zone that talks to the model.
- Network: default-deny plus an egress allowlist to the model endpoint only, via an auditing proxy. Because the zone network is
--internaland all egress funnels through the allowlist proxy, even a stolen key can only reachapi.anthropic.com:443- arbitrary exfiltration destinations drop at the network layer, and the proxy’s request log becomes a receipt line. - Credentials: exactly one per-run minted, short-TTL, spend-capped key. Never a
~/.claudebind-mount. - Filesystem: RW
/work, RO everywhere else, and no$HOME- so there is no~/.claudeto read in the first place.
For the concrete isolate flags that wire this up (--egress-allow, --key-env, --mint-cmd / --revoke-cmd) and how the model is chosen in --exec, see Models and Agents.
Exec zone
Section titled “Exec zone”Untrusted code execution: check, build, test.
- Network:
none. This is correct here precisely because nothing in this zone needs to phone out. - Hermetic: frozen clock, seed, TZ, and locale; OS-assigned (
:0) ports; ephemeral fixtures; pinned tool versions. This is the same hermeticity the determinism contract requires of any check declared deterministic. - Ephemeral: reset each run. State from one iteration cannot leak into the next.
--network none is the right answer for the exec zone and the wrong answer for the agent zone. The single-container design failed because it tried to apply one network policy to both.
Why a git worktree is not a security boundary
Section titled “Why a git worktree is not a security boundary”A git worktree is the ergonomic way to give the loop its own checkout. It is not a boundary. A worktree shares the main repository’s object database, packed-refs, refs/heads/* (including main), config, remotes, and hooks. A confused or injected loop inside a worktree can:
git push origin HEAD:mainusing the inherited credential helper - straight to prod.git branch -f main <evil>- rewrite the host’s branches.git gcthe shared object store - corrupt the host repo.- Plant
.git/hooks/post-commit- code that runs on the host’s next git operation. That is a sandbox escape.
The contract uses a detached clone instead - a separate object DB with no credentialed route out:
git clone --no-hardlinks --no-local --single-branch --branch "$BASE" \ "file://$HOST_REPO" "$SANDBOX/work"cd "$SANDBOX/work"git remote remove origin # no route to prodgit config --local core.hooksPath /dev/null # sandbox can't plant host hooksgit config --local credential.helper "" # no inherited credentialsThe only remote is a local bare mirror in a quarantined namespace; a reviewed PR bot promotes to origin. Server-side branch protection on main/release is the backstop. Client trust is never the boundary - the server is.
Why ~/.claude:ro is theater
Section titled “Why ~/.claude:ro is theater”The single-container design mounted ~/.claude:ro as its one secret control. :ro blocks writes, not reads. The agent that - as established - must have network can read ~/.claude/.credentials.json and exfiltrate a long-lived key for every project on the machine, not just this run. Read-only protects the file from the agent; it does nothing to protect the secret from being read and shipped out.
The contract replaces the bind-mount with a per-run minted credential:
- Scoped to
model:invoke, TTL <= loop budget, provider-side spend cap. - Injected as one env var into the agent zone only, revoked on any exit via an
EXITtrap. - For Anthropic concretely: a dedicated Workspace per run with a hard spend limit and a workspace-scoped Admin-API key, deleted at loop end. Where per-key caps are unavailable, the auditing proxy tallies cost from each response’s
usageblock and kills the key at the cap. Fail closed if usage is unparseable.
The blast radius of a stolen key collapses from “every project, indefinitely” to “this run’s model endpoint, until this run ends, capped in dollars.”
The isolation contract, as configuration
Section titled “The isolation contract, as configuration”The Planned loop.yml isolation block is the shape doctor will validate:
isolation: boundary: detached_clone # a worktree is ergonomic, NOT a boundary clone: core_hooks_path: /dev/null # sandbox cannot plant host hooks remove_origin: true # no credentialed route to prod agent_zone: # reasoning surface: needs narrow model egress network: egress_allowlist egress_allowlist: ["api.anthropic.com:443"] # via auditing proxy; default deny proxy_logs_in_receipt: true fs: { rw: ["/work"], default: ro, no_home: true } # NO $HOME, NO ~/.claude exec_zone: # untrusted CODE execution network: none hermetic: true # frozen clock/seed/TZ/locale, :0 ports ephemeral: true # reset each run secrets: ephemeral_credential: true # minted per run; TTL <= loop budget; revoked at end on_credential_home_mount: credential_scope_invaliddoctor preconditions (fail-closed)
Section titled “doctor preconditions (fail-closed)”Isolation is a gate the category claim trails, not leads. doctor is specified to refuse to start the loop when isolation is unsatisfiable. The marketing is gated on a green doctor. The contract preconditions:
| Precondition | Violation -> halt reason | Exit class |
|---|---|---|
network: none paired with a cloud / local-host model | isolation_unsatisfiable | 30 |
Any ~/.claude (or ...:/root/.claude) bind-mount present | credential_scope_invalid | 13 |
exec_zone.network is anything other than none | isolation_unsatisfiable | 30 |
| No egress allowlist on the agent zone | isolation_unsatisfiable | 30 |
worktree boundary + credentialed origin | warning (use a detached clone) | - |
| Agent egress observed outside the allowlist at runtime | hermeticity_violation | 14 |
These are fail-closed: an unprovable precondition halts the loop rather than starting it. See the full halt reason map for the exit-code classes CI branches on.
Blast radius, summarized
Section titled “Blast radius, summarized”Two-zone isolation does not make the agent trustworthy. It makes the worst case bounded and named:
- A stolen key reaches one endpoint, for one run, under a dollar cap - not every project, indefinitely.
- Untrusted code runs with no network and a reset-each-run filesystem - it cannot phone out or persist.
- A
git push origin HEAD:mainhas no route - the clone is detached, the credential helper is empty, branch protection is the backstop. - A planted
post-commithook runs nothing -core.hooksPathis/dev/null.
This is the same discipline as the rest of loop engineering: a property is not asserted once and assumed; it is enforced as an invariant, fail-closed, and recorded in the receipt.