Skip to content

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 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 -e shell 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.

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.

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 --internal and all egress funnels through the allowlist proxy, even a stolen key can only reach api.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 ~/.claude bind-mount.
  • Filesystem: RW /work, RO everywhere else, and no $HOME - so there is no ~/.claude to 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.

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:main using the inherited credential helper - straight to prod.
  • git branch -f main <evil> - rewrite the host’s branches.
  • git gc the 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:

Terminal window
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 prod
git config --local core.hooksPath /dev/null # sandbox can't plant host hooks
git config --local credential.helper "" # no inherited credentials

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

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 EXIT trap.
  • 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 usage block 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 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_invalid

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:

PreconditionViolation -> halt reasonExit class
network: none paired with a cloud / local-host modelisolation_unsatisfiable30
Any ~/.claude (or ...:/root/.claude) bind-mount presentcredential_scope_invalid13
exec_zone.network is anything other than noneisolation_unsatisfiable30
No egress allowlist on the agent zoneisolation_unsatisfiable30
worktree boundary + credentialed originwarning (use a detached clone)-
Agent egress observed outside the allowlist at runtimehermeticity_violation14

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.

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:main has no route - the clone is detached, the credential helper is empty, branch protection is the backstop.
  • A planted post-commit hook runs nothing - core.hooksPath is /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.