Skip to content

chore: make the OS sandbox the safety boundary; drop the Bash guard hooks - #588

Merged
johanzander merged 8 commits into
mainfrom
chore/drop-permission-guard-hooks
Aug 14, 2026
Merged

chore: make the OS sandbox the safety boundary; drop the Bash guard hooks#588
johanzander merged 8 commits into
mainfrom
chore/drop-permission-guard-hooks

Conversation

@johanzander

@johanzander johanzander commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Makes the OS sandbox the safety boundary, cuts approvals to the two commands whose effects escape the repository, and removes the two Bash permission hooks that were standing in for both. An agent should run implement-issue end-to-end and stop exactly once, at the design confirm gate.

Note

Verified. A fresh-session verify-sandbox.sh run passes every check, gh and podman included. 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.json 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. Iterating on sandbox.* 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

Setting
sandbox enabled, allowWrite: ["."], excludedCommands: ["gh"]
defaultMode auto — unlisted commands go to a classifier, never to you
ask git push --force *, sudo *
deny podman machine rm, podman system reset, every mutating git stash form

That is the entire list. rm -rf, git reset --hard, rebase, merge, git branch -D, git worktree remove all run unattended.

Why rm -rf is safe to leave unattended

The 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-create and file-write-unlink in a single rule, so rm -rf ~ cannot create or unlink.

allowWrite must name the repo root, and that is the whole trick. Writes are allowOnly minus denyWithinAllow, and the built-in allowOnly is only /dev/*, /tmp/claude, ~/.npm/_logs, ~/.claude/debug — the repository is not in it.

The abandoned chore/sandbox-replaces-worktree-hook branch set allowWrite: [".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 allowRead differs), so no allowWrite entry overrides the built-in denyWrite list.

Denied Consequence
.git/config, .git/worktrees Create worktrees with EnterWorktree, never git worktree add from Bash — the harness is not sandboxed
.claude/**, scripts/**, .github/** Edit with the Edit/Write tools, which the sandbox does not govern; Bash writes there fail
package.json, package-lock.json npm install fails. worktree-setup.sh symlinks node_modules, so this bites only when a branch needs its own deps
A git merge origin/main touching .claude/ or scripts/ fails; finish it with Edit/Write
.git/objects, refs and index are not denied → commit, branch, reset, reflog work normally

Hooks

Hook Fate
auto-allow-worktree-destructive.sh removed — a cwd-conditional auto-allow; the sandbox does this job from the syscall
link-worktree-local-settings.sh removed — symlinked settings.local.json into worktrees to undo the asymmetry the first hook created
check-worktree-path.sh kept
pretest-on-commit, optimizer_core_principles, lint-edited-python untouched (quality, not permissions)

The 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.sh stays because it is a different shape: it guards Edit/Write rather than Bash, compares two git rev-parse results 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: auto

Load-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 default mode that is 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.

It lives 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 the gitignored settings.local.json is precisely 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.

git stash

Denied rather than prompted, because it is the one command that destroys another agent's uncommitted work rather than your own — one refs/stash per repository, shared by every worktree, with no owner. Every mutating form is listed; list/show still work. The rules match the command as written, so git -C <dir> stash pop slips past where the hook normalised it — noted in CLAUDE.md.

scripts/verify-sandbox.sh

Ported from the abandoned branch and corrected. Two 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, and creating one is itself blocked — so every candidate target is one of your 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.

Measured results

scripts/verify-sandbox.sh, run via the Bash tool with the sandbox live:

Check Result
write outside the repo is blocked PASSrm -rf is contained; this was the whole point
write inside the repo is allowed PASSallowWrite: ["."] resolves correctly
git worktree add is blocked PASS (expected — use EnterWorktree)
Bash writing .claude/settings.json is blocked PASS (expected — use Edit/Write)
Bash writing scripts/** allowed (informational)
gh reaches GitHub FAIL
podman reaches its VM FAIL

Two earlier claims are withdrawn as measured-false:

  • The deny list is per-file inside .claude, not the whole directory. touch .claude/.sandbox-probe succeeds. 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 that scripts/, .github/ and the lockfiles are denied came from misreading the binary's GitHub Actions default config (it lists ~/actions-runner and GITHUB_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:

Knob The measured failure it answers
enableWeakerNetworkIsolation ghx509: OSStatus -26276. Go binary can't reach trustd to verify TLS. Egress was never the issue — curl https://github.com returned 200 in the same sandbox. Explicitly weaker: it opens trustd.
network.allowMachLookup (SecurityServer, securityd, trustd) gh auth status"The token in keyring is invalid". The macOS keychain is XPC, not network. Same block made git push emit failed to store: 100001 (push still landed).
network.allowLocalBinding podman infodial tcp 127.0.0.1:64752: connect: operation not permitted. The VM is reached over local TCP, so allowUnixSockets/allowRead were both wrong.
allowWrite: [".", "~/GitHub/bess-manager"] frontend/node_modules and .venv are symlinks into the main checkout; writes through them resolve outside a worktree's root → EPERM on node_modules/.vite-temp, breaking vitest. Worktrees are nested inside the main checkout, so one entry covers both.

The hardcoded ~/GitHub/bess-manager is ugly in a tracked file. It's there because the alternative — a user-level allowWrite — is refused by the auto-mode classifier, correctly: that would be a session widening its own containment.

sandbox.excludedCommands is 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, gh and podman included.
  • ./scripts/quality-check.sh — 0 errors, 0 warnings.
  • The script gains a probe it was missing: nothing exercised the symlinked dependency trees, yet that was a real blocker. It doesn't present as a permissions problem — it surfaces as vitest failing to load its config with EPERM, breaking Step 6 in every worktree for no visible reason. A check now fails loudly if that allowWrite entry is ever removed.

Coverage limits, stated rather than glossed: the run exercises gh auth status (keychain + TLS — 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.

🤖 Generated with Claude Code

https://claude.ai/code/session_019LeVZcjErmJnJKa4ix4BLE

johanzander and others added 2 commits August 14, 2026 22:31
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
@johanzander johanzander changed the title chore: replace the permission-guard hooks with plain settings rules chore: cut permissions to what escapes the repo; drop the Bash guard hooks Aug 14, 2026
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
@johanzander
johanzander marked this pull request as ready for review August 14, 2026 20:49
… 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
@johanzander johanzander changed the title chore: cut permissions to what escapes the repo; drop the Bash guard hooks chore: make the OS sandbox the safety boundary; drop the Bash guard hooks Aug 14, 2026
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
@johanzander johanzander changed the title chore: make the OS sandbox the safety boundary; drop the Bash guard hooks chore: sandbox as the safety boundary — containment verified, gh/podman/frontend blocked Aug 14, 2026
johanzander and others added 3 commits August 14, 2026 23:28
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
@johanzander johanzander changed the title chore: sandbox as the safety boundary — containment verified, gh/podman/frontend blocked chore: make the OS sandbox the safety boundary; drop the Bash guard hooks Aug 14, 2026
@johanzander

Copy link
Copy Markdown
Owner Author

sandbox trial

@johanzander
johanzander merged commit 0579ffb into main Aug 14, 2026
8 checks passed
@johanzander
johanzander deleted the chore/drop-permission-guard-hooks branch August 14, 2026 22:25
@johanzander

Copy link
Copy Markdown
Owner Author

Code review (post-merge)

Reviewed chore/drop-permission-guard-hooks (merged as 0579ffb9) — deletes the two Bash permission hooks, enables the OS sandbox, rewrites the permission lists, adds scripts/verify-sandbox.sh.

High — permission patterns don't match the forms they claim to

  1. .claude/settings.json:59 — the git stash deny list enumerates literal subcommands, so option-first forms match nothing and fall through to defaultMode: auto: git stash -u, -k, -p, --include-untracked, --all. -u is one of the most common forms. CLAUDE.md:256 claims the deny covers "every mutating form"; the old hook normalised the command, so this is a regression, not a pre-existing gap. Fix: add Bash(git stash -*) and Bash(git stash --*).

  2. .claude/settings.json:76Bash(git push --force *) is a literal prefix match, so git push -f origin main and bare git push --force (no args) match nothing — and Bash(git push *) is in the allow list at line 43, so both run silently. The one shape the PR says still asks does not reliably ask.

  3. .claude/settings.json:76 — none of the old hook's GitHub-reaching guards survive: pushes moving a shared ref (main, beta, beta-release-*, tags) are allowed outright, and gh pr merge / release / repo edit / secret / workflow run / non-GET gh api are unlisted so auto decides. The sandbox provides no containment for network effects, so "the sandbox is the safety boundary" doesn't cover this class. Concretely: an autonomous run can now git push beta main or gh pr merge a release PR unattended — both of which the project's own rules require explicit instruction for.

Medium

  1. .claude/settings.json:76 — the safety argument for leaving rm, git reset --hard, git branch -D unattended rests on "tracked content survives in the object database, discarded commits survive by SHA in the reflog". But git gc --prune=now, git reflog expire --expire=now --all and git tag -d previously asked and are now unattended, and .git/objects/refs are deliberately not sandbox-denied. The recovery mechanism the risk model depends on can itself be destroyed without a prompt.

  2. scripts/verify-sandbox.sh:85 — the worktree probe decides success from [ -d "$probe_wt" ] rather than git worktree add's exit status. git worktree add creates the target directory before writing .git/worktrees, and .claude/worktrees/<name> is not on the deny list (the script itself notes at line 118 that only specific .claude files are denied). So a correctly-blocked run can leave the directory behind → wt=created → FAIL reported on a correct config, git worktree remove --force then fails silently, stray directory left behind. wt_err is captured on line 84 but never printed in the blocked branch, so the real error is lost either way.

  3. scripts/verify-sandbox.sh:141 — check 4c is the only guard against dropping the ~/GitHub/bess-manager allowWrite entry, but it can't discriminate when run from the main checkout, which is the default place to run it: there frontend/node_modules is a real directory inside ., so the probe passes regardless. The claim that "a check now fails loudly if that allowWrite entry is ever removed" only holds inside a worktree. It should assert it's running in a linked worktree (or skip loudly) before treating a PASS as meaningful.

  4. scripts/verify-sandbox.sh:165 — the podman remedy text still says "add it to sandbox.filesystem.allowRead or exclude podman", which is the wrong knob this PR spent several commits disproving (the fix is network.allowLocalBinding, per CLAUDE.md). When this check next fails it sends the reader down the exact dead end the PR documented.

Low

  1. scripts/verify-sandbox.sh:156 — same for the gh remedy: it recommends sandbox.excludedCommands and "move the exclusion to ~/.claude/settings.json", while CLAUDE.md now records excludedCommands as unused/ineffective and notes a user-level widening is refused by the auto-mode classifier.

  2. CLAUDE.md:238 — "Worktrees are nested inside the main checkout, so one entry covers both" is false for the sibling-folder layout (../bess-manager-feature/), which CLAUDE.md:180 still presents as first-class. A sibling worktree is under ~/GitHub/, not ~/GitHub/bess-manager; it only works because "." resolves to the session's own cwd, and a main-checkout session operating on a sibling is silently blocked.

  3. scripts/quality-check.sh:150 — the gate that ran the permission-decision test matrix is removed with nothing replacing it. verify-sandbox.sh can't fill the gap: it exits 2 unless run by the Bash tool in a sandboxed session, so it can never be a CI/pre-commit check. The permission surface is now entirely unguarded by automation — the condition fix: make quality-check find venv tools and fail when it cannot #562's "a gate that cannot run its checks must not report success" principle was added to prevent.


Findings 1–3 are the ones worth fixing first; they're straightforward pattern additions to .claude/settings.json.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant