27. Guard the outer PATH against executables a session leaves behind#
Date: 2026-10-06
Status#
Superseded by ADR 28 except for the entry-point mount guard. The watcher shipped only in the 5.0.0 betas.
Builds on ADR 9 (the shadow on PATH, Invariant 1) and ADR 26 (the Python shadow). Applies to the Python shadow only.
Context#
The sandbox contains the agent while it runs. It does not contain code the agent writes that something outside the sandbox runs later. The workspace, the project venv and the tool caches are writable from the jail on purpose, and the user, VS Code and the container entrypoint run things from them outside it.
The sharpest form of that is PATH. In the published image and in DLS
python-copier devcontainers, the container’s PATH starts with a project
venv’s bin (/opt/venv/bin, a link to /cache/venv/bin, or
/cache/venv-for<workspace>/bin), ahead of /usr/local/bin and the system
directories, and the shipped conf has allow-write = /cache so that uv
works in the jail. An executable a session leaves in that bin therefore
shadows a system command for every outer shell, VS Code’s git panel and the
entrypoint, during the session (the user runs git push in another
terminal) and after it. If it is named claude, it shadows the sandbox
itself, and the next plain claude runs outside the jail (Invariant 1).
Git hooks are the other quiet route: .git/hooks is in the writable
workspace, a hook never appears in a diff, and the next outer git commit
or git push runs it.
Protecting a list of names is whack-a-mole: git, python3, ls, sudo
and every other command on PATH is a target. What matters is that a session
added an executable that a later PATH directory also has.
Decision#
Quarantine executables a session adds ahead of system commands on PATH, and new git hooks, while the session runs; warn in outer shells.
Entry-point mount guard. For every writable directory that precedes
/usr/local/binon the launching PATH and exists at launch,bwrap.pyread-only binds/dev/nulloverclaude,codex,piandclaude-sandbox, after the read-write binds. Inside the jail those names cannot be created, written, replaced or removed. The shadow refuses to launch when one of them is anything else there, and battery check 22 asserts the binds.A live watcher in the launcher (
watch.py). Outside the jail, for as long as the session runs, it watches every writable directory (inside a read-write bind) that precedes the last system command directory (/usr/sbin,/usr/bin,/sbin,/bin) on PATH, and the workspace’s git hooks directory (worktrees included). inotify, through the standard library’sctypes, wakes it at once; a pass every second finds directories that appear later and is all there is where inotify is unavailable.In a PATH directory, an executable, or a link to one, whose name a later PATH directory also has is a shadow. The watcher clears its execute bits through a descriptor opened without following links; a link is removed and its target recorded. A re-
chmod +xis quarantined again.In the hooks directory, any new or changed executable but
*.sampleloses its execute bits. A directory the repository’score.hooksPathnames is watched the same way when it lies inside a read-write root; the watcher reads the setting withgit config --get(git from the fixed tool path, a scrubbed environment, fsmonitor off), and a change of the setting during the session is an alert of its own.What was present and unchanged when the session started is left alone, so the venv’s own
python3stays. A changed file is judged again.One allowance: a link named
python,python3orpython3.Nwhose final target is an executable namedpython*outside every read-write root of the jail is not a shadow. That is whatuv venvmakes against/usr/bin/python3or a root-owned uv-managed Python, so a session can recreate the venv. A regular file of that name, a link into anything the session can write, and every other name (pip, console scripts) are judged as usual.
A scan at launch. Before the agent starts, each watched directory is compared with a baseline the previous launch kept under
/run/claude-sandbox(root-owned); a shadow that is not in it is quarantined and reported as a launch warning, so the pause shows it. The first launch only records the baseline.With the jail off the shadow execs
script(1), so a forked child watches instead. It leaves the terminal’s session and stops within a second of the launch ending. When the shadow is PID 1 (the image’s default command) the orphaned watcher would be reparented toscriptitself, so therescriptruns as a child and the watcher is a thread, as with the jail on.Surfacing. Each action is a line in
/run/claude-sandbox/alerts(/tmp/claude-sandbox/alertsif/runcannot be written), whichbwrap.pymasks inside the jail. A root-owned/etc/profile.d/claude-sandbox-alerts.sh, sourced from the system bash and zsh rc files, prints new alerts in red at every outer prompt; with none it costs twotest -sbuiltins. The jailed launch prints the same summary when the session ends.claude-sandbox alertslists them, and--clearempties the list and takes what the directories hold now as the new baseline, so a file the user has reviewed and restored stands.claude-sandbox doctorreports the entry points and any alerts.
Options rejected:
Reorder PATH only. Putting the system directories first can’t be enforced in a guest devcontainer without editing its
devcontainer.json, which the installer refuses to do, and the venv first on PATH is what the project expects.Narrow
allow-write. Dropping/cachebreaksuv sync,uv addand the shared uv cache in the jail.A protected-tool list. Whack-a-mole, as above; kept only for the sandbox’s own four names, where a mount can stop the write altogether.
Detect only at session exit. Too late for an outer shell the user is using while the session runs.
Consequences#
An in-session install of a console script that shadows a system tool (a package whose script is also in
/usr/bin) is quarantined. Restore it and runclaude-sandbox alerts --clear, or install outside the jail.The interpreter allowance holds only where the interpreter is out of the session’s reach. In the published image uv’s Pythons are under
/usr/libexec/claude-sandbox/python(the sandbox’s own, root-owned and not bound read-write), and in a DLS python-copier devcontaineruv venvlinks to the system/usr/bin/python3. But a copier project whoserequires-pythonthe system Python does not meet gets a uv-managed Python in uv’s default~/.local/share/uv/python, which the jail can write (~/.local/shareis bound read-write). There a venv the session recreates loses itspythonlinks to quarantine. Recreate such a venv outside the jail. Trusting that directory would trust an interpreter the session can rewrite, so it is not done; binding it read-only into the jail would let the allowance apply.The entry-point binds leave empty, non-executable files named
claude,codex,piandclaude-sandboxin the guarded directories, and a session cannot remove the venv’sbinwhile they are mounted.The system rc files gain a hook, installed and removed by the installer between markers.
inotify is Linux only, through
ctypes; elsewhere, or if_ctypesis missing, the watcher polls every second. Phase 4’s interpreter pruning must keep_ctypes.Only the Python shadow does this; the bash shadow is being retired.
Not covered: a directory that does not exist at launch has no mount guard (the watcher and the next launch’s checks still apply); a change of
core.hooksPathbetween sessions (only one during a session alerts);.git/configsettings other thancore.hooksPath; and code in the workspace, the venv’ssite-packagesor the caches that the user runs outside the sandbox. Review that like any contribution.The threat model gains a section, “What a session leaves behind”.