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
| File | Lines of code | What it is |
agents/worktree.py | ~59 | Make the tree, then keep or remove it |
sandbox/base.py | ~45 | The port, the lookup, the recommendation |
sandbox/none.py | ~16 | The default: no walls, said out loud |
sandbox/bwrap.py | ~24 | Linux, via bubblewrap |
sandbox/seatbelt.py | ~26 | macOS, via sandbox-exec |