[Bug] Sandbox profile E2BIG with many git worktrees due to unbounded ancestor rule expansion

Status Open
Reported on v2.1.92
Maintainer reply None cached
Activity 4 comments · opened Jul 2, 2026

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:

  1. In a git repo, create ~60+ worktrees (git worktree add).
  2. Run any sandboxed Bash command.
  3. → 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

[]

View original on GitHub ↗

4 Comments

switchbm · 1 month ago

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 literal true. 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):

  • argv assembly passes the full Seatbelt profile inline:

$=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 (macOS ARG_MAX = 1,048,576 incl. envp).

  • per write-deny path the generator emits 1 (deny file-write* (subpath …)) plus msa() adds 2 more subpath rules (file-write-unlink, file-write-create) plus 2 literal rules per ancestor directoryusa() walks dirname all the way to /, no dedup across entries. Each rule is 3 lines carrying the full path and a (with message …) tag.
  • the worktree enumeration adds 3 deny paths per registered worktree (config.worktree, config.worktree.lock, commondir under .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):

  1. Pass the profile via sandbox-exec -f <tmpfile> instead of inline -p — removes profile size from the argv budget.
  2. Dedupe ancestor literal rules 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.
  3. Collapse the per-worktree entries into a single (deny file-write* (subpath "<repo>/.git/worktrees")) rule with the specific allow-exceptions carved out, making profile size independent of worktree count.
switchbm · 1 month ago

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:

  1. Per-worktree settings probing: with --debug-file, the log shows Broken 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).
  2. Quoting inflation: the profile is embedded inside a /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 -ww and KERN_PROCARGS2 both 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.

nate-getea · 1 month ago

Is there a plan to resolve this? It is a serious degradation rendering sandboxing essentially non functional for us

zenprocess · 1 month ago

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 registered projects in ~/.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.json held ~1,250 projects entries.
  • ~92% of them pointed at directories that no longer existed (≈ 1.06 MB of derived argv on their own).
  • Live git worktrees were a comparatively minor contributor (a few dozen).
  • Pruning only the entries whose directory was missing dropped the profile well under the argv limit and immediately restored Bash — so the dead project entries, not the worktrees, were the dominant source in this case.

Because both the worktree list and the projects list ultimately emit deny-paths, a single guard covers both: skip any deny-path whose target directory no longer exists — one isdir() 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 stale projects entries on startup (or a claude project prune that drops missing-dir entries) would also help, but the isdir skip 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.