[Bug] Sandbox profile E2BIG with many git worktrees due to unbounded ancestor rule expansion
Bug Description
Title: Sandbox Seatbelt profile grows ~7–15 KB per git worktree → E2BIG on all sandboxed commands (regression in v2.1.196)
Summary: On macOS, sandbox-exec profile is passed inline in argv. Since v2.1.196 the profile generator enumerates .git/worktrees/* and injects 3 write-deny paths per registered worktree (config.worktree, config.worktree.lock, commondir). The pre-existing ancestor-expander (msa) then emits (deny file-write-unlink/-create …) rules for each path and every ancestor directory, ×2 kinds, ×3 tagged lines, with no dedup across entries. So the profile scales ~linearly with worktree count and crosses the ~1.04 MB single-exec argv limit, after which every sandboxed Bash command fails with E2BIG: argument list too long, posix_spawn '/bin/zsh' — even trivial ones.
Impact: Repos with many Claude Code worktrees become completely unusable in sandbox mode. Cliff is ~50 worktrees. Confusingly, the session that hit it stays broken after cleanup because the profile is cached at session start.
Regression window (verified by diffing on-disk binaries):
- 2.1.92, 2.1.114 — no .git/worktrees reference in profile; immune at any worktree count
- 2.1.196 (2026-06-30) — per-worktree enumeration added ← regression
- 2.1.197, 2.1.198 — same
Repro:
- In a git repo, create ~60+ worktrees (git worktree add).
- Run any sandboxed Bash command.
- → E2BIG … posix_spawn '/bin/zsh'.
Suggested fix: Dedup the ancestor (deny … (literal \<dir\>)) rules across entries, or replace per-worktree enumeration with a single (subpath "<repo>/.git/worktrees") deny, or bound/omit the (with message …) tag on ancestor rules. Any one collapses the size dramatically.
Env: macOS (Darwin 25.2.0), arm64, Claude Code 2.1.198, getconf ARG_MAX = 1048576; measured effective single-exec argv limit ≈ 1.044 MB.
Environment Info
- Platform: darwin
- Terminal: ghostty
- Version: 2.1.198
- Feedback ID: f695d7e6-f10c-4f4f-8ca9-44ce1578e4f3
Errors
[]
4 Comments
Independently hit and root-caused this today before finding this issue — can confirm the analysis. Same bug as #73468.
Environment: Claude Code
2.1.198, macOS Darwin 24.6.0 (arm64), sandbox enabled, monorepo with 177 registered git worktrees.Symptoms: every Bash tool call fails
E2BIG: argument list too long, posix_spawn '/bin/zsh'— including a literaltrue. Shell commands executed through an MCP server in the same session (plain/bin/sh -c, ~4 KB env, no sandbox wrapper) work fine, isolating the failure to the sandbox spawn path.Verified in the 2.1.198 binary (strings extraction of the bundled sandbox runtime):
$=bV(["env",...H,...N,...D,"/usr/bin/sandbox-exec","-p",I,M,"-c",t])— then the whole string is run via/bin/zsh -c, so profile bytes count against the single-exec argv budget (macOSARG_MAX= 1,048,576 incl. envp).(deny file-write* (subpath …))plusmsa()adds 2 more subpath rules (file-write-unlink,file-write-create) plus 2 literal rules per ancestor directory —usa()walksdirnameall the way to/, no dedup across entries. Each rule is 3 lines carrying the full path and a(with message …)tag.config.worktree,config.worktree.lock,commondirunder.git/worktrees/<name>/), each ~10 directories deep → ~690 deny paths → profile alone exceeds 1 MiB. Cliff behaviour: everything works until worktree count crosses the threshold, then 100% of sandboxed commands fail regardless of command length.Suggested fixes (any one of the first two removes the cliff entirely):
sandbox-exec -f <tmpfile>instead of inline-p— removes profile size from the argv budget.literalrules across deny entries — the 690 worktree paths share all but the last 2 path components, so the ancestor set collapses from ~13,800 rules to ~190.(deny file-write* (subpath "<repo>/.git/worktrees"))rule with the specific allow-exceptions carved out, making profile size independent of worktree count.Follow-up with measured data (supersedes the estimates in my previous comment). Still reproducible on
2.1.199— changelog mentions no fix.Empirical threshold bisect — single-variable, reproducible without touching working trees: move
.git/worktrees/<name>admin dirs aside to change the registered count, then probe with a fresh headless process per step (claude -p 'Use the Bash tool to run exactly: echo probe' --allowedTools Bash):| registered worktrees | Bash tool |
|---|---|
| 1 | ✅ works |
| 8 | ✅ works |
| 11 | ❌ E2BIG |
| 14 | ❌ E2BIG |
| 26 | ❌ E2BIG |
So the practical ceiling is ~8–10 registered worktrees, i.e. an effective argv cost of ~75 KB per worktree — considerably worse than the ~7–15 KB/worktree in the issue description. Since all working-tree directories stayed on disk during the bisect (only registrations moved), disk content is irrelevant; registration count alone decides.
Two amplifiers appear to explain the gap between "3 deny paths per worktree" and 75 KB:
--debug-file, the log showsBroken symlink or missing file encountered for settings.json at path: …/worktrees/<name>/…for every registered worktree — the harness treats each worktree as a potential settings source and appears to add its config-path guards to the profile, each ancestor-expanded (~10 dirs deep → ~23 rules per path)./bin/zsh -c "env … sandbox-exec -p '<profile>' …"string, so every quote/paren in the SBPL pays escaping overhead on top of the raw rule text.Also, the static base profile alone exceeds 256 KB at zero worktrees. A diagnostic gotcha for anyone reproducing: macOS
ps -wwandKERN_PROCARGS2both fail to return argv beyond ~256 KB (ps silently falls back to the executable name), so the assembled command is invisible to normal inspection — we verified the 256 KB blindness with a synthetic 400 KB-argv process.Happy to run further diagnostics on this machine if useful — it reproduces 100% deterministically at ≥11 worktrees.
Is there a plan to resolve this? It is a serious degradation rendering sandboxing essentially non functional for us
There's a second accumulation source for this same profile-size failure that's worth folding into the fix, since it points at a simpler general remedy than per-source dedup.
Beyond
.git/worktrees/*, the profile builder also derives write-deny paths from the registeredprojectsin~/.claude.json. An entry is added the first time Claude runs in a directory, but it's never removed when that directory is deleted. On any workflow that creates and tears down many working directories (worktrees, CI checkouts, ephemeral scratch dirs), these entries accumulate without bound — independently of how many live git worktrees exist.Concrete data point from a heavy-automation setup on macOS (
ARG_MAX≈ 1 MB):~/.claude.jsonheld ~1,250projectsentries.Because both the worktree list and the
projectslist ultimately emit deny-paths, a single guard covers both: skip any deny-path whose target directory no longer exists — oneisdir()filter applied before rule emission. A deny-rule for a nonexistent directory is never useful anyway, so this is safe, it's O(1) stat per entry at profile-build time, and it holds regardless of which source accumulates. Auto-pruning staleprojectsentries on startup (or aclaude project prunethat drops missing-dir entries) would also help, but theisdirskip in the profile builder is the defense-in-depth that can't be re-defeated by accumulation — it caps profile size at "paths that actually exist" no matter what registers them.Worth noting for anyone hitting this: it's unrecoverable in-session (you can't run a shell to fix it), and the entries silently re-accumulate after a manual prune until something caps the profile, so a builder-side guard rather than a one-time cleanup is what actually closes it.