A Tour of the Harness · v2 · M21

Isolation

Two containments that both refuse rather than pretend: a git worktree per subagent, and the platform's own sandbox around a shell call. Three stops, about forty minutes.

Built M21
Kept in sync by a test

Everything before this milestone decided whether something may run. This one is about what happens while it runs: a subagent that may write gets a copy of the tree to write in, and a shell command gets the operating system's own walls around it. Neither half invents a policy. The worktree moves the working directory, which is an input the permission engine already reads; the sandbox is handed a decision the permission engine already made. Both fail loudly rather than quietly doing less than they promised, which is the only interesting design constraint on this page.

flowchart TD
    fm["isolation: worktree in the frontmatter"] --> repo{"inside a git repository?"}
    repo -- "no" --> refuse["refuse, and do not run the agent"]
    repo -- "yes" --> add["git worktree add -b edgar/agent-session"]
    add --> cwd["Session.cwd moves there"]
    cwd --> rules["every path rule is rebased with it"]
    rules --> done["the turn runs"]
    done --> clean{"git status --porcelain"}
    clean -- "empty" --> rm["worktree remove; the branch keeps the commits"]
    clean -- "anything" --> keep["KEEP it, and name it in the summary"]

Stage 1. A tree of its own about 15 minutes

1A worktree per subagent

agents/worktree.py, agents/spawn.py

Look for: create finish Worktree _closed

The whole mechanism is one line in spawn(): session.cwd = tree.path. Everything else follows from that for free. ToolContext.cwd reads from Session.cwd, and Guard.check rebuilds its Policy around that cwd on every single call. So the existing rule — a path outside the working directory is an Ask — now points at the worktree, and the parent's real files sit outside it. This is not a new rule. It is the old rule aimed somewhere else.

A worktree is cut from HEAD, the parent's last commit. So a human halfway through an edit when a subagent starts is never at risk: the subagent cannot see that edit and cannot touch it. Worth knowing before you trust this — which is also why the test for it is named after that fact, not after the function.

Two refusals do the real work here:

  • Outside a git repository there is no worktree to make, so create returns a refusal and the agent does not run at all. Never a quiet fallback to sharing the tree — an agent that asked for isolation and silently didn't get it is worse than one that fails outright.
  • finish never passes --force, to git or to anything. If git status --porcelain shows anything at all, the worktree is kept: its path and branch go in the summary, along with the line telling a human how to delete it. The harness will leave a stray directory behind before it will throw away work it did not write.

That summary is appended to the tool result — the same text the model reads next and the human sees in the transcript. There is no second channel and no log file to remember to check. If you learn about a kept worktree a week later, it's because you scrolled back to the call, or because git worktree list and git branch still show it.

Take with you: the cheapest isolation is the one that reuses a variable the rest of the system already reads. Adding an isolated flag to the permission engine would have been a second mechanism to keep correct forever; moving cwd was one assignment.

Stage 2. The platform's own walls about 25 minutes

2The Sandbox port, and the backend that does nothing

sandbox/base.py, sandbox/none.py, permissions/policy.py, cli/doctor.py

Look for: Sandbox wrap available backend best network_allowed

The port is one pure function. A backend does not run your command — it says what argv would run it confined, and run_argv starts that instead. That's the one place in edgar that starts a subprocess, and the one place that knows how to kill a process group on Ctrl-C. The Blueprint's first sketch gave the protocol an async run(...). Turning it into wrap(...) -> list[str] bought two things: there is still exactly one launcher, and a command line can be checked on a machine that can't even run it. That second one matters more than it sounds — a sandbox you can't test is a sandbox you're trusting on faith, and most of what goes wrong with one is a wrong flag.

none.py is sixteen lines that accept every argument and ignore all of them. It's the default, and naming it in the config is how you say out loud "I am not sandboxed." The alternative — letting sandbox silently mean "whatever happens to be installed" — is exactly the failure this milestone exists to prevent.

Two functions carry the honesty:

  • backend(name) — raises if the configured backend is unknown or just plain missing, so a machine without bwrap fails to start a session instead of quietly running everything unconfined.
  • best() — walks the list backwards and returns the strongest backend actually present. edgar doctor prints it next to what you have configured, as a suggestion. It never changes the setting itself — quietly turning a sandbox on would still be a machine widening policy, which nothing automated may do.

Then there's network_allowed(mode, tainted), which lives in permissions/ and not here — on purpose. A backend enforces a decision; it must never make one. The permission engine computes the answer fresh for every call, because an earlier call in the same turn can taint the session, carries it on ToolContext.network, and hands it to the backend as a plain boolean. Decisions flow one way only, and a test checks the sandbox is never looser than the engine that told it what to do.

Take with you: a port that returns data instead of performing an effect can be tested everywhere, by everyone, on every platform. Pick that shape whenever the effect is the part you cannot reproduce.

3bubblewrap and seatbelt

sandbox/bwrap.py, sandbox/seatbelt.py

Look for: Bubblewrap Seatbelt profile

Fifty lines for both platforms combined, because neither file actually builds a sandbox — the kernel and the OS already did that. Each one just translates the same three facts (the command, which directories the harness says are writable, and the network answer) into the local dialect.

  • Linux. The dialect is an argv: bind the whole filesystem read-only, bind the named roots back in writable, give it a fresh /tmp, and add --unshare-net when the answer was no.
  • macOS. The dialect is a small Lisp — (allow default), then (deny file-write*), then one (allow file-write* (subpath …)) per root, in that order, because in SBPL the later rule wins.

Read the two files side by side and it's the same sentence, said twice.

Two details are worth stopping on:

  • The seatbelt profile goes straight into the argv, not into a temporary file. Nothing is left behind, and there's no window where another process could swap the file between it being written and it being run.
  • The writable set is built by the harness itself, from cwd and the blob directory — never from anything the model said. A tool argument cannot widen it, because it never even reaches this code.

Now the uncomfortable part, said plainly, because a tour that hides it is worse than no tour at all. This is a write and network boundary, not a read boundary. A confined command can still read your whole filesystem. That is what the Blueprint designed and what these twenty-five lines each do. But the roadmap line for this milestone also promised "cannot read outside the allowed roots" — a promise these backends do not keep. The contradiction is written down in the ADR instead of papered over, because a sandbox people believe does more than it really does is the exact failure this milestone exists to avoid.

Take with you: when the spec and the implementation disagree about a security boundary, the honest move is to ship the narrower claim and write down the gap. Nobody is hurt by a boundary that is smaller than advertised; everybody is hurt by one that is believed to be larger.

Sizes

FileLines of codeWhat it is
agents/worktree.py~59Make the tree, then keep or remove it
sandbox/base.py~45The port, the lookup, the recommendation
sandbox/none.py~16The default: no walls, said out loud
sandbox/bwrap.py~24Linux, via bubblewrap
sandbox/seatbelt.py~26macOS, via sandbox-exec