chore: make the OS sandbox the safety boundary; drop the Bash guard hooks - #588
Conversation
The three permission hooks are removed. auto-allow-worktree-destructive.sh auto-allowed Bash whose cwd was a linked worktree, check-worktree-path.sh blocked Edit/Write outside the current checkout, and link-worktree-local-settings.sh symlinked settings.local.json into each worktree to undo an asymmetry the first hook created. They existed because settings.json cannot express a cwd-conditional rule. That capability was not worth what it cost: six distinct false positives in a single day, every one introduced by the fix to the previous one, including blocking a live beta release. There is no cwd inspection left to get wrong -- the rules now apply identically in the main checkout and in every worktree. What the hooks denied that settings must still deny moves into permissions.deny/ask: every mutating `git stash` form (list/show still work), and `sudo`. The podman VM denials were already there. The remaining prompts are git reset/rebase/merge, rm, and force-push. Two behaviours are deliberately not carried over: - Bash in a worktree no longer skips the `ask` list, so `rm` and `git reset` prompt everywhere. That is the cost of having no cwd-conditional rule. - Edit/Write in the main checkout is no longer blocked. Working in a worktree stays the documented convention; it is no longer enforced. The OS sandbox cannot take over this job, and CLAUDE.md now records why so it is not investigated a third time. Claude Code's write policy is `allowOnly` minus `denyWithinAllow` with no allow-within-deny primitive for writes, and its default denyWrite list covers .claude, scripts, .github, .git/config and .git/worktrees -- so allowWrite structurally cannot re-open them, and with the sandbox on Bash cannot `git worktree add` or complete a branch switch. quality-check.sh drops the gate that ran the deleted hook's test matrix. Full gate passes: 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeVZcjErmJnJKa4ix4BLE
… guard Two corrections to the previous commit. The ask list kept prompting on rm, git reset/rebase/merge and reset --hard. That inverts the cost: a prompt stalls an autonomous run for certain, while every one of those commands is recoverable. Tracked content survives `rm` and `reset --hard` via the object database, and a discarded commit survives by SHA in the reflog. Only two shapes still ask, and both reach past the repository where nothing local contains them: `git push --force` and `sudo`. The podman VM and `git stash` stay denied. That is the whole list; everything else runs unattended. check-worktree-path.sh is restored. It was removed as a "permission hook", but it guards Edit/Write rather than Bash and it is the one guard here that has never produced a false positive -- it compares two `git rev-parse` results as strings and refuses an edit aimed at a different checkout than the session's cwd. That is the failure this repo actually hits, a stale absolute path writing into the main checkout while ~20 worktrees are live, and it caught exactly that while this branch was being written. Unlike the Bash guards it never parses a command string, which is what made those unfixable. CLAUDE.md now states the standard directly: add to the ask list only when the effect escapes the repo and git cannot undo it, never because a command looks dangerous. It also records what the unattended set does risk -- uncommitted, untracked work, the one thing git cannot recover -- since the sandbox that would have contained it is unavailable. Full gate passes: 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeVZcjErmJnJKa4ix4BLE
Without this the previous two commits make autonomous runs worse, not better. Cutting the ask list controls what prompts, but everything UNLISTED is decided by permissions.defaultMode, which was set nowhere. Under the plain `default` mode that means a prompt for every command outside the allow list -- compose, npm, python3, mkdir, cp and a dozen one-off shapes a single implement-issue run reaches for. What used to cover that was the auto-allow hook (which allowed everything in a worktree) plus a bypassPermissions defaultMode in the gitignored settings.local.json, now empty. The hook is gone; the mode was never replaced. `auto` sends unlisted commands to a classifier instead of to the user. `deny` and `ask` still bind on top of it, so the podman VM and git stash denials keep their teeth. It goes in the TRACKED settings deliberately. defaultMode is not a Bash rule, so it travels into every worktree on its own -- no hook required. Keeping it in settings.local.json is exactly what made an autonomous run in the main checkout start prompting the moment it entered a worktree, and what link-worktree-local-settings.sh existed to paper over. Full gate passes: 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeVZcjErmJnJKa4ix4BLE
… root This is what makes leaving `rm` unattended defensible: writes are confined by the OS from the actual syscall, and outside the repo the macOS profile denies file-write-create and file-write-unlink in one rule, so `rm -rf` cannot escape. The previous attempt (chore/sandbox-replaces-worktree-hook, abandoned) set `allowWrite: [".claude", ".git", "scripts"]` -- three paths that are all on the built-in denyWrite list -- and never opened the repository root. The built-in allowOnly is /dev/*, /tmp/claude, ~/.npm/_logs and ~/.claude/debug; the repo is not in it. That config therefore allowed essentially nothing, every git operation failed, and the conclusion drawn was that the sandbox is unusable here. The conclusion was wrong; the config was. `allowWrite: ["."]` is the fix. What cannot be re-opened stays honest in CLAUDE.md rather than discovered again: writes are allowOnly minus denyWithinAllow with no allow-within-deny primitive, so the built-in denies on .claude, scripts, .github, .git/config, .git/worktrees and the lockfiles are final. The consequences are worked out there -- create worktrees with EnterWorktree not Bash, edit .claude/scripts with the Edit/Write tools which the sandbox does not govern, and expect npm install and a .claude-touching merge to fail. .git/objects, refs and the index are not denied, so commit/branch/reset/reflog are unaffected. scripts/verify-sandbox.sh is ported from the abandoned branch and corrected. Two of its checks asserted the wrong outcome: `git worktree add` and a Bash write to .claude/** were expected to SUCCEED, so a correct config scored two failures. They now assert blocked, and a green run means the sandbox behaves as CLAUDE.md claims. Its `rm -rf` probe was dropped rather than written: proving an unlink outside the repo is blocked needs a file outside the repo that already exists, creating one is itself blocked, so every candidate target is one of the user's real files -- a probe that deletes ~/.zshrc when the sandbox is off is worse than no probe. Check 0 covers it soundly, since create and unlink are denied by the same rule. NOT VERIFIED IN THE SESSION THAT WROTE IT, and it cannot be: policy is read once at session start. Run `bash scripts/verify-sandbox.sh` in a fresh session on this branch before merging. Full quality gate passes: 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeVZcjErmJnJKa4ix4BLE
Every claim here is now measured rather than derived from reading the binary,
and three of them were wrong.
1. Sandbox policy is NOT read once at session start. `sandbox.*` live-reloads
when a settings file is edited: editing .claude/settings.json activated the
sandbox mid-session, and that session then verified its own config. The
opposite is asserted all over the abandoned sandbox branch, was repeated
into CLAUDE.md and into this script's header, and is false. A fresh session
is not required; running it via the Bash TOOL is, since a terminal
invocation is unsandboxed and produces a result-shaped non-result.
2. The denyWrite list is per-file inside .claude, not the whole directory.
`touch .claude/.sandbox-probe` SUCCEEDS -- the old check 4 probed a path
nothing denies and would have reported the directory writable. It now probes
.claude/settings.json, which is denied. `scripts/**` is measured writable,
so the earlier claim that scripts/, .github/ and the lockfiles are denied is
withdrawn; it came from misreading the binary's GitHub Actions default
config (it lists ~/actions-runner and GITHUB_EVENT_PATH) as the local one.
3. Both integration failures have causes other than their old remedy text:
- gh fails on the macOS KEYCHAIN, not the network: "The token in keyring is
invalid". enableWeakerNetworkIsolation addresses TLS/trustd and would not
have helped.
- podman fails on a local TCP port, not a unix socket: "dial tcp
127.0.0.1:64752: connect: operation not permitted". filesystem.allowRead
and network.allowUnixSockets are both the wrong knob.
Still unresolved, and recorded in CLAUDE.md as such rather than papered over:
gh and podman remain broken, which blocks implement-issue Steps 9 and 8.
excludedCommands in ~/.claude/settings.json and network.allowLocalBinding in
project settings were both tried; neither took effect. Also,
frontend/node_modules is a symlink into the main checkout, so writes through it
land outside a worktree's allowWrite root and fail EPERM -- that breaks vitest
and vite build, i.e. Step 6, in every worktree.
Measured PASS: writes outside the repo blocked (so `rm -rf` is contained),
writes inside allowed, `git worktree add` blocked, `.claude/settings.json`
blocked.
DO NOT MERGE until gh, podman and the node_modules symlink are resolved --
enabling this as-is breaks the E2E run, PR creation and the frontend gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeVZcjErmJnJKa4ix4BLE
gh auth status fails reading the token from the macOS keychain; a call that gets past that then fails with x509: OSStatus -26276 because gh is a Go binary and cannot reach trustd to verify TLS. Only the second is what enableWeakerNetworkIsolation is documented for, so that knob alone is not a fix. The same keychain block is why git push emits 'failed to store: 100001' while still landing the push. Also records that the Bash tool's dangerouslyDisableSandbox works as a per-call escape hatch -- used to reach GitHub with the sandbox live -- while noting it cannot carry an autonomous Step 9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeVZcjErmJnJKa4ix4BLE
The sandbox captures its policy ONCE, at activation, and ignores every later settings edit. The previous commit claimed the opposite and it was wrong in the direction that matters. The trap is that it looks like live-reload: editing settings.json mid-session DOES activate the sandbox, so the editing session finds itself sandboxed and concludes the config re-reads. It does not. A probe write to a path added to allowWrite minutes earlier still returned Operation not permitted -- that is what exposed it. Consequence: the four knobs added here for gh, podman and the symlinked dependency trees are reasoned from measured error messages, NOT verified. The session that added them could not test them. The earlier observation that excludedCommands does nothing is now suspect for the same reason, since it was made after activation. Knobs, each matched to an error actually observed: - enableWeakerNetworkIsolation -- gh is a Go binary and returned 'x509: OSStatus -26276' reaching GitHub; curl to the same host returns 200, so egress is fine and only trustd TLS verification is blocked. - network.allowMachLookup for SecurityServer/securityd/trustd -- gh returned 'The token in keyring is invalid', a keychain block, not a network one. - filesystem.allowWrite += ~/GitHub/bess-manager -- frontend/node_modules and .venv are symlinks into the main checkout, so writes through them land outside a worktree's own root and fail EPERM, breaking vitest and vite build. Worktrees are nested inside the main checkout, so allowing it covers both. The hardcoded path is ugly in a tracked file; it is there because the alternative, a user-level allowWrite, is refused by the auto-mode classifier -- correctly, since that would be a session widening its own containment. - network.allowLocalBinding retained for podman's 127.0.0.1:64752 connection, though binding and outbound-local may well be different permissions. Verify with ONE fresh session running scripts/verify-sandbox.sh via the Bash tool. Not mergeable until that passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeVZcjErmJnJKa4ix4BLE
A fresh-session verify-sandbox.sh run passed every check, including gh and podman. The four knobs added blind in the previous commit -- enableWeakerNetworkIsolation, network.allowMachLookup, network.allowLocalBinding and the widened allowWrite -- were exactly the hypothesis that run was waiting on, and they work. CLAUDE.md's 'Known-broken (unresolved)' section is replaced by one entry per knob recording which measured failure each answers, so none gets dropped later as redundant. excludedCommands is recorded as unused and unnecessary. It was tried in both project and user settings while these four knobs were missing, appeared to do nothing, and is moot now. The script gains the probe it was missing. Nothing exercised the symlinked dependency trees, yet that was a real blocker: in a worktree frontend/node_modules and .venv are symlinks into the MAIN checkout, so writes through them resolve outside the worktree's own root. It does not present as a permissions problem -- it surfaces as vitest failing to load its config with EPERM on node_modules/.vite-temp, breaking Step 6 in every worktree for no visible reason. That is what the main-checkout allowWrite entry is for, and now a check fails loudly if it is ever removed. Coverage limits recorded rather than glossed: the run exercises gh auth status (keychain + TLS, which is most of what gh pr create needs) and podman info (the socket connection only, not that a compose E2E completes). A full Step 8 and a real gh pr create remain the first end-to-end proof. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeVZcjErmJnJKa4ix4BLE
|
sandbox trial |
Code review (post-merge)Reviewed High — permission patterns don't match the forms they claim to
Medium
Low
Findings 1–3 are the ones worth fixing first; they're straightforward pattern additions to |
Makes the OS sandbox the safety boundary, cuts approvals to the two commands whose effects escape the repository, and removes the two
Bashpermission hooks that were standing in for both. An agent should runimplement-issueend-to-end and stop exactly once, at the design confirm gate.Note
Verified. A fresh-session
verify-sandbox.shrun passes every check,ghandpodmanincluded. Check 0 did not fire, so it was a genuinely sandboxed run.The sandbox captures its policy once, at activation, and ignores later edits. This bit twice. It looks like live-reload — editing
.claude/settings.jsondoes activate the sandbox, so the editing session finds itself sandboxed and concludes the config re-reads. It does not: a probe write to a path added toallowWriteminutes earlier still returnedOperation not permitted. Iterating onsandbox.*in one session produces confident results measured against the original config. One edit, one fresh session, one run — never a second edit in the same session.The whole permission surface, after this PR
enabled,allowWrite: ["."],excludedCommands: ["gh"]auto— unlisted commands go to a classifier, never to yougit push --force *,sudo *podman machine rm,podman system reset, every mutatinggit stashformThat is the entire list.
rm -rf,git reset --hard,rebase,merge,git branch -D,git worktree removeall run unattended.Why
rm -rfis safe to leave unattendedThe sandbox confines every Bash write to the repository, decided by the OS from the actual syscall rather than guessed from a command string. Outside the repo the macOS profile denies
file-write-createandfile-write-unlinkin a single rule, sorm -rf ~cannot create or unlink.allowWritemust name the repo root, and that is the whole trick. Writes areallowOnlyminusdenyWithinAllow, and the built-inallowOnlyis only/dev/*,/tmp/claude,~/.npm/_logs,~/.claude/debug— the repository is not in it.The abandoned
chore/sandbox-replaces-worktree-hookbranch setallowWrite: [".claude", ".git", "scripts"]: three paths that are all on the built-in deny list, never opening the repo root. It therefore allowed essentially nothing, every git operation failed, and its tip commit concluded the sandbox was unusable here. The conclusion was wrong; the config was. This PR supersedes that branch.What stays denied, and what it costs
There is no allow-within-deny primitive for writes (reads have one, which is why
allowReaddiffers), so noallowWriteentry overrides the built-indenyWritelist..git/config,.git/worktreesEnterWorktree, nevergit worktree addfrom Bash — the harness is not sandboxed.claude/**,scripts/**,.github/**package.json,package-lock.jsonnpm installfails.worktree-setup.shsymlinksnode_modules, so this bites only when a branch needs its own depsgit merge origin/maintouching.claude/orscripts/fails; finish it with Edit/Write.git/objects, refs and index are not denied → commit, branch, reset, reflog work normallyHooks
auto-allow-worktree-destructive.shlink-worktree-local-settings.shsettings.local.jsoninto worktrees to undo the asymmetry the first hook createdcheck-worktree-path.shpretest-on-commit,optimizer_core_principles,lint-edited-pythonThe two removed hooks cost six distinct false positives in a single day, every one introduced by the fix to the previous one, including blocking a live beta release. They tried to infer which files a command would touch by parsing the command string; the sandbox is what that code was failing to be.
check-worktree-path.shstays because it is a different shape: it guards Edit/Write rather than Bash, compares twogit rev-parseresults as strings, never parses a command, and has never produced a false positive. It also catches what the sandbox structurally cannot — the Edit/Write tools are not sandboxed at all, so a stale absolute path writing into the main checkout while ~20 worktrees are live is only caught here. It caught exactly that while this branch was being written.defaultMode: autoLoad-bearing, and the gap in the first two commits. Cutting the ask list controls what prompts; everything unlisted is governed by the mode, which was set nowhere. Under the plain
defaultmode that is a prompt for every command outside theallowlist — compose, npm, python3, mkdir, cp and a dozen one-off shapes a singleimplement-issuerun reaches for.It lives in the tracked settings deliberately:
defaultModeis not a Bash rule, so it travels into every worktree on its own — no hook required. Keeping it in the gitignoredsettings.local.jsonis precisely what made an autonomous run in the main checkout start prompting the moment it entered a worktree, and whatlink-worktree-local-settings.shexisted to paper over.git stashDenied rather than prompted, because it is the one command that destroys another agent's uncommitted work rather than your own — one
refs/stashper repository, shared by every worktree, with no owner. Every mutating form is listed;list/showstill work. The rules match the command as written, sogit -C <dir> stash popslips past where the hook normalised it — noted in CLAUDE.md.scripts/verify-sandbox.shPorted from the abandoned branch and corrected. Two checks asserted the wrong outcome —
git worktree addand a Bash write to.claude/**were expected to succeed, so a correct config scored two failures. They now assert blocked, and a green run means the sandbox behaves as CLAUDE.md claims.Its
rm -rfprobe was dropped rather than written: proving an unlink outside the repo is blocked needs a file outside the repo that already exists, and creating one is itself blocked — so every candidate target is one of your real files. A probe that deletes~/.zshrcwhen the sandbox is off is worse than no probe. Check 0 covers it soundly, since create and unlink are denied by the same rule.Measured results
scripts/verify-sandbox.sh, run via the Bash tool with the sandbox live:rm -rfis contained; this was the whole pointallowWrite: ["."]resolves correctlygit worktree addis blockedEnterWorktree).claude/settings.jsonis blockedscripts/**ghreaches GitHubpodmanreaches its VMTwo earlier claims are withdrawn as measured-false:
.claude, not the whole directory.touch .claude/.sandbox-probesucceeds. The ported check probed exactly that path and would have reported the directory writable; it now probes.claude/settings.json, which is denied.scripts/**is writable. The claim thatscripts/,.github/and the lockfiles are denied came from misreading the binary's GitHub Actions default config (it lists~/actions-runnerandGITHUB_EVENT_PATH) as the local one.Why each non-default knob exists
Every one was found by reading an actual error message. Every wrong guess along the way came from reasoning about what ought to be blocked. Don't drop one because it looks redundant:
enableWeakerNetworkIsolationgh→x509: OSStatus -26276. Go binary can't reachtrustdto verify TLS. Egress was never the issue —curl https://github.comreturned 200 in the same sandbox. Explicitly weaker: it openstrustd.network.allowMachLookup(SecurityServer,securityd,trustd)gh auth status→ "The token in keyring is invalid". The macOS keychain is XPC, not network. Same block madegit pushemitfailed to store: 100001(push still landed).network.allowLocalBindingpodman info→dial tcp 127.0.0.1:64752: connect: operation not permitted. The VM is reached over local TCP, soallowUnixSockets/allowReadwere both wrong.allowWrite: [".", "~/GitHub/bess-manager"]frontend/node_modulesand.venvare symlinks into the main checkout; writes through them resolve outside a worktree's root →EPERMonnode_modules/.vite-temp, breakingvitest. Worktrees are nested inside the main checkout, so one entry covers both.The hardcoded
~/GitHub/bess-manageris ugly in a tracked file. It's there because the alternative — a user-levelallowWrite— is refused by the auto-mode classifier, correctly: that would be a session widening its own containment.sandbox.excludedCommandsis not used and not needed. It was tried in both project and user settings while the four knobs above were missing, appeared to do nothing, and is moot now.Verification
scripts/verify-sandbox.sh— all checks PASS in a fresh session,ghandpodmanincluded../scripts/quality-check.sh— 0 errors, 0 warnings.EPERM, breaking Step 6 in every worktree for no visible reason. A check now fails loudly if thatallowWriteentry is ever removed.Coverage limits, stated rather than glossed: the run exercises
gh auth status(keychain + TLS — most of whatgh pr createneeds) andpodman info(the socket connection only, not that a compose E2E completes). A full Step 8 and a realgh pr createremain the first end-to-end proof.🤖 Generated with Claude Code
https://claude.ai/code/session_019LeVZcjErmJnJKa4ix4BLE