Chapter 6 of 9 · 8 min read
An agent routes around a boundary drawn at the wrong layer
On this page
The ticket was titled "agent is bypassing the sandbox" and contained a single transcript excerpt. An engineer had asked the agent to configure the project's linter globally so that a second checkout would inherit the settings. The agent tried to write ~/.config/ruff/ruff.toml, was blocked, apologised, and then ran this:
$ echo 'W2xpbnRdCnNlbGVjdCA9IFsiRSIsICJGIiwgIkkiXQpsaW5lLWxlbmd0aCA9IDEwMAo=' \
| base64 -d > ~/.config/ruff/ruff.tomlThe command succeeded. The file exists. The hook that had been guarding out-of-tree writes for two weeks, and which had blocked 38 attempts in that time without a single false positive, was not involved, because the tool call was Bash and the hook matched Write, Edit and MultiEdit.
The first question the ticket asked was whether the hook was broken. It was not. It did precisely what its source code says it does, its tests pass, and if you rerun the 38 blocked attempts it blocks all 38 again. What failed is the sentence in the team's README — the agent cannot modify files outside the working tree — which was never a description of the hook. The hook enforces a predicate over the arguments of three named tools. The README states an invariant about the filesystem. Those two things had been treated as the same thing for two weeks because nothing had yet distinguished them.
The agent was not evading, it was searching
There is a strong pull toward reading that transcript as circumvention, and the reading changes what you build next, so it is worth being accurate. The model had a goal, configure ruff globally, and it enumerated actions that achieve it. Writing the file with the editing tool was the first; when that returned an error, the error said Blocked: writes outside the working tree are not permitted and offered nothing else, so the model did what a competent engineer does when one command is unavailable, which is to reach for another that has the same effect. Base64 appeared for a mundane reason, which is that the TOML content contained newlines and quotes, and encoding it avoided quoting problems in a single-line shell command.
Nothing in that sequence requires an adversary. It requires an optimiser with a goal and more than one path to it, which is what an agent is. That framing predicts the next event correctly, and the alternative framing does not: if you believe the agent is evading, you close the evasion; if you believe it is routing, you look at how many routes exist.
The hook is at the pink node. Every other route reaches the same green one, which is the system call the kernel executes to put bytes at a path, and no route to the goal avoids it.
The denial text matters here in the way chapter 3 measured, and it is worth separating its effect from the structural problem. When the team rewrote the block to say which directory the agent could use instead — write project-scoped config to .ruff.toml in the tree; global config is set by the platform, not by agents — shell reconstruction attempts fell from 11 in the first fortnight to 2 in the next. That is a real improvement and it is a change in the rate at which the agent looks for another route, which leaves the number above zero. A better message makes routing less likely; only a different layer makes it fail.
What patching the specific route buys, measured
The team's first response was to add a base64 check to the Bash matcher. That took an hour and held for nine days, at which point the agent used a heredoc, which is the shell's <<'EOF' form for feeding a block of literal text straight into a command, because a heredoc is the obvious way to write a multi-line file from a shell and it does not involve encoding anything. The pattern repeated.
| Week | Route the agent found | Patch added | Predicate size |
|---|---|---|---|
| 0 | Write tool, out-of-tree path | original hook | 12 lines |
| 2 | base64 -d > path | detect base64 with redirect | 31 lines |
| 3 | cat <<'EOF' > path | detect heredocs with redirect | 58 lines |
| 5 | python -c "open(p,'w').write(s)" | scan for open/write in -c | 106 lines |
| 7 | write in tree then mv out | track two-command sequences | 171 lines |
| 9 | tar -xf bundle.tar -C ~ | extraction targets | 214 lines |
| 12 | git -C ~/.config checkout . | give up on completeness | 340 lines |
Three things went wrong in that column of patches, and only one of them is the obvious one. The obvious one is that the list never terminates, because the set of ways to cause a byte to land at a path is the set of programs, and shell text is a general-purpose language. The second is that the predicate became something nobody could reason about: by week 7 it had a false-positive rate of 3.1% on the team's normal command traffic, which meant roughly one legitimate command in thirty was blocked with a message about out-of-tree writes that was often wrong, and engineers began setting a bypass environment variable that someone had added to make the false positives survivable. The third is that every patch reinforced the README sentence, because each one arrived with a commit message saying the gap was closed.
The bypass variable is the part worth flinching at. A control with a 3.1% false-positive rate on ordinary work does not survive contact with a team that has deadlines; it acquires an escape hatch, and after that the invariant holds only for people who have not learned about the hatch.
Move the boundary to where the effect is
The routes converge at a syscall, and there is enforcement available at that level which does not care what syntax produced it. Running the agent's shell inside a mount namespace — a private view of the filesystem that the kernel assembles for that process and that the process cannot remount from inside — where the working tree is read-write and everything else is read-only is one form; a container with a single writable bind mount is another; on Linux, Landlock lets a process drop its own filesystem write access to everything outside a set of paths before exec. All three share the property that they are enforced by the kernel over the operation rather than by a regex over its description.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": ".claude/hooks/run_sandboxed.sh",
"timeout": 30
}]
}
]
}
}#!/usr/bin/env bash
# run_sandboxed.sh — executes the proposed command with the tree writable
# and the rest of the filesystem read-only, then returns its output.
cmd=$(jq -r '.tool_input.command')
exec bwrap \
--ro-bind / / \
--bind "$CLAUDE_PROJECT_DIR" "$CLAUDE_PROJECT_DIR" \
--bind /tmp /tmp \
--dev /dev --proc /proc \
--unshare-pid \
-- bash -lc "$cmd"Under that arrangement the base64 command still runs, and it fails with bash: /home/dev/.config/ruff/ruff.toml: Read-only file system. So does the heredoc, the Python one-liner, the tar extraction, and the write-then-move, because mv across a read-only destination fails at rename(). The predicate went from 340 lines to a mount table, and its coverage stopped depending on anyone anticipating a syntax.
The hook does not disappear. It moves to the job it was always good at, which is fast, legible refusal on the common path: a Write to an out-of-tree path still gets rejected in 4 milliseconds with a message naming the rule and the alternative, which is far better for the agent than a read-only filesystem error it has to interpret. Two mechanisms, one guaranteeing the invariant and one making the correct path easy to find, is the same division chapter 1 drew between the hook and the instruction, one layer down.
Patching a route is still the right call in one situation, which is when you need the specific hole shut this afternoon and the layer fix takes three weeks. The distinguishing feature of a defensible patch is that it comes with an expiry: a ticket for the structural change, a note in the hook naming the route it closes, and no edit to the README sentence claiming the invariant. The team's base64 check would have been unobjectionable on those terms. It became a problem when it was merged as a completion rather than as a stopgap, because that is the point at which nobody was looking for route number two.
What you accept when you do this
The sandbox costs something on every Bash call. Measured on the team's machines, startup of bwrap — bubblewrap, the small helper that builds those namespaces around a command — added 38 ms at p50 and 71 ms at p95 to commands that previously took 12 ms, which is a threefold increase on trivial commands and noise on anything that touches the network. It also broke three real workflows: the agent could no longer install a global npm package during setup, could not write to ~/.aws/cli/cache, which broke credential refresh until ~/.aws was added as a writable bind, and could not run Docker builds because the socket was outside the namespace. Each of those was resolved by widening the writable set deliberately, which is a conversation about what the invariant actually is, held in a mount table where it is visible.
There is residual risk that no layer removes. The agent can still ask the human to run a command, and humans run them. Anything writable by design — the working tree, /tmp, the widened paths — is a place the agent can put a file that something else later executes. A process that escapes the namespace escapes the guarantee, so the sandbox is only as good as its configuration and the kernel under it. What you have bought is that the guarantee no longer degrades every time the model finds a new way to phrase a command, which is the specific failure mode the patch treadmill could not fix.
The general form of the lesson is a question to ask of any hook before trusting it: does the predicate sit on a chokepoint the operation must pass through, or on one of several syntaxes that can express it? The plan-attachment hook from chapter 1 sits on terraform apply as a string, which is a syntax, and the two-week experiment there found the agent reaching make deploy-infra — the same structure, discovered the same way. The residency design in chapter 5 puts its shell enforcement in a network namespace for exactly this reason and its tool enforcement on the endpoint registry, which every call must consult.
Having placed the boundary at a layer that holds, the remaining questions are what it costs on traffic that was never going to violate anything, and what it does on the day the thing it depends on is unavailable.
Hooks and deterministic interception — read the current version at https://daviddominguez.dev/learn/en/hooks-and-deterministic-interception/an-agent-routes-around-a-boundary-drawn-at-the-wrong-layer