macOS Bash sandbox: profile grows ~4.5KB per deny path (no dedup of ancestor-literal rules), exceeds ARG_MAX and breaks every sandboxed command

Status Closed — duplicate
Reported on v2.1.220
Maintainer reply None cached
Activity 1 comment · opened Aug 3, 2026 · closed Aug 15, 2026

Environment

  • Claude Code: 2.1.220 (native arm64 build, ~/.local/bin/claude)
  • macOS: 26.6 (SIP enabled), ARG_MAX = 1,048,576
  • Repo on an external volume: paths ~12 directories deep

(/Volumes/<disk>/<org>/<product>/github/<repo>/...)

  • 16 registered Claude-created git worktrees (.claude/worktrees/*)

Symptom

Every sandboxed Bash tool call fails at spawn — including true:

Could not start /bin/zsh: the command line plus environment exceed the OS exec
argument limit (E2BIG). At spawn: command line 1.1MB across 3 args (largest
single arg 1.1MB); environment 2.8KB across 49 vars (largest: PATH at 1KB).
The Bash sandbox profile adds 228 filesystem deny paths to every command, 48
of them for registered git worktrees, which grow this list without bound. ...

The session is unusable in sandboxed mode until worktrees are deleted and
Claude Code is restarted (the profile appears to be computed once per session).

Root cause

The sandboxed spawn inlines the entire generated seatbelt profile into a
single argument:

/bin/zsh -c "env … /usr/bin/sandbox-exec -p '<profile>' <shell> -c '<cmd>'"

The profile builder expands each filesystem deny path into ~26 rules. For
each of the ops (file-write-unlink, file-write-create) it emits:

  • one (deny <op> (subpath "<path>") (with message "…")) rule,
  • one (deny <op> (literal "<parent-dir>") (with message "…")) rule,
  • **one (deny <op> (literal "<ancestor>") (with message "…")) rule for every

ancestor directory up to /** (a dirname-walk helper).

There is no dedup across deny paths: all 228 paths share the same first
~10 ancestors (/Volumes, /Volumes/<disk>, …), so the identical
ancestor-literal rules are re-emitted 228 times each. With ~12-deep paths and
a (with message …) suffix on every rule, each deny path costs ~4–5KB of
profile text → 228 × ~4.5KB ≈ 1.1MB > ARG_MAX.

The deny-path count itself is dominated by worktrees: each registered
.claude/worktrees/* contributes ~14 deny paths (its .claude/settings.json,
settings.local.json, skills, hooks, commands, agents, workflows,
.mcp.json, …), so 16 worktrees ≈ 224 of the 228 paths. Worktrees accumulate
naturally from normal session use, so any long-lived repo on a deep path will
eventually hit this cliff.

Suggested fixes

  1. Dedup the ancestor-literal rules across deny paths — emit each distinct

(deny <op> (literal "<dir>")) once. For n paths sharing a deep common
prefix this is roughly a 10–25× reduction (the ancestor rules dominate).

  1. Pass the profile via a temp file (sandbox-exec -f /path/profile.sb)

instead of inline -p, removing the ARG_MAX ceiling entirely.

  1. Shorten or drop the per-rule (with message …) payload.
  2. Consider pruning deny paths for worktrees whose directories no longer exist

(or auto-git worktree prune candidates) when building the profile.

(1) and (2) are independent; (2) alone fixes the hard failure, (1) also cuts
kernel profile-evaluation cost.

Reproduction sketch

  1. Clone any repo to a deep path (≥10 directories).
  2. Register ~16 worktrees under .claude/worktrees/ (normal EnterWorktree use).
  3. Start Claude Code with the default Bash sandbox; run any Bash command →

E2BIG at spawn.

Workaround (what we did)

Removed 13 stale merged worktrees (git worktree remove + prune), restarted
Claude Code → profile drops to ~0.3MB and spawns succeed.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗