macOS sandbox unusable: Seatbelt profile passed inline via 'sandbox-exec -p' exceeds ARG_MAX with many git worktrees
Environment
- Claude Code:
2.1.197 - OS: macOS (Darwin, arm64)
- Sandbox: enabled (default)
Summary
On macOS, every sandboxed Bash command fails with:
E2BIG: argument list too long, posix_spawn '/bin/zsh'
…including trivial commands like printf ok. The sandbox becomes 100% unusable and the only workaround is disabling it entirely. The environment is tiny (~80 vars, ~12 KB), so the environment is not the cause — the sandbox wrapper itself is.
Root cause
Claude Code builds the macOS Seatbelt profile and passes it inline as a command-line argument. The assembled invocation is (paraphrased from the bundled @anthropic-ai/sandbox-runtime):
env <unset/set env vars> /usr/bin/sandbox-exec -p "<ENTIRE SEATBELT PROFILE>" /bin/zsh -c "<user command>"
…which is then shell-quoted and executed as /bin/zsh -c "<that whole string>". So the full profile lives inside argv, and argv + envp must fit within ARG_MAX (1,048,576 bytes on macOS).
The profile's file-write section grows linearly with the number of git worktrees/repos under the configured working directories. For each worktree and repo it emits one (deny file-write* …) rule per git config file, e.g.:
(deny file-write* (literal "<repo>/.git/worktrees/<name>/config.worktree") (with message "…"))
(deny file-write* (literal "<repo>/.git/worktrees/<name>/config.worktree.lock") (with message "…"))
(deny file-write* (literal "<repo>/.git/worktrees/<name>/commondir") (with message "…"))
…
On a developer machine with a large number of git worktrees across the working roots (order of ~1,000 worktrees / ~100 repos in my case), this profile crosses ~1 MB. Once it does, the outer posix_spawn('/bin/zsh', …) fails with E2BIG before any command runs — so nothing executes and the sandbox is completely broken.
Steps to reproduce
- On macOS, configure a workspace whose working directories contain (directly or via an additional working directory) a large number of git repos/worktrees — enough that the generated Seatbelt profile exceeds ~1 MB.
- With the sandbox enabled, run any Bash command, e.g.
printf ok. - Observe
E2BIG: argument list too long, posix_spawn '/bin/zsh'.
Expected: the command runs under the sandbox regardless of how many worktrees/repos exist.
Actual: every sandboxed command fails with E2BIG; the sandbox is unusable until disabled.
Diagnostics gathered
getconf ARG_MAX→1048576- Environment size → ~12 KB (not the cause)
- Sandbox disabled → all commands work
- Sandbox enabled → even
printf okfails - Reconstructing the profile from git state confirms the file-write deny rules are the dominant term and scale with worktree/repo count
Suggested fixes
- Primary: write the profile to a temp file and use
sandbox-exec -f <file>instead of-p <inline>. File-based profiles have noARG_MAXlimit and this is a minimal change. - Alternative: use the in-process
sandbox_init(3)API (already referenced in the binary) rather than exec-ingsandbox-execwith the profile inargv. - Secondary (defense-in-depth): consolidate the git-protection rules so profile size doesn't grow linearly with worktree count — e.g. one
(deny file-write* (subpath "<repo>/.git"))-style rule per repo instead of one literal per config file per worktree, and/or cap the number enumerated. - Guard: if the assembled command would exceed
ARG_MAX, fall back to a file-based profile (or at minimum emit a clear diagnostic instead of a rawE2BIG).
Impact
Sandbox mode is unusable on any machine with a large git-worktree footprint in scope, forcing users to disable the sandbox to get any work done.
Showing cached comments. Read the full discussion on GitHub ↗
6 Comments
Confirming this independently on a different machine (Darwin 24.x, arm64, ~40 worktrees in a single repo), and adding a live capture of the actual spawn payload plus one contributor not yet mentioned here.
Captured payload (intercepted the real
sandbox-execinvocation)I shadowed
envearlier inPATHwith a passthrough that logsargv/envpbyte sizes when the args containsandbox-exec, thenexec /usr/bin/env "$@". The harness'senv … /usr/bin/sandbox-exec -p … /bin/zsh -c …hits it. From an affected session:So the profile alone is ~906 KB and the whole spawn is at ~89% of
ARG_MAXat the repo root; a worktree-launched session adds more enumerated paths and tips it over 1 MB →E2BIGbefore the command runs. (Confirmed the single-arg ceiling on this machine is exactly 1 MB: a 1016 KB arg spawns, 1024 KB fails.) This corroborates the "profile inargvvsARG_MAX" root cause with a measured payload rather than a reconstruction.Additional dominant contributor: a redundant per-rule LogTag (~220 KB)
Beyond the git-worktree deny rules, every rule in the profile carries the same
(with message "CMD64_<base64>_END_<id>_SBX")annotation. In a sampled profile there were ~1,275 such annotations at ~177 bytes each ≈ 220 KB of duplicated text — the largest single non-path term, and it does not scale down with worktree count.Sampled composition (~347 KB profile, ~3,977 lines; sizes vary per session):
| Profile content | Count |
|---|---|
|
(deny file-write-create (subpath …))| 582 ||
(deny file-write-unlink (subpath …))| 582 ||
(literal …)read-allow entries | 1,053 ||
(subpath …)entries | 183 ||
(regex …)entries | 45 || repeated
(with message "CMD64_…_SBX")| 1,275 (~220 KB) |On the fixes
Strong +1 on (1)
sandbox-exec -f <tempfile>— that removes the profile fromargventirely and makes profile size irrelevant toARG_MAX, which is the complete fix. Worth adding: hoisting the repeated(with message …)LogTag to a single declaration (instead of ~1,275 copies) reclaims ~220 KB on its own — enough to bring the observed heavy case back under 1 MB even if the profile stays inline. So it's a cheap, high-value complement to the-fchange and a reasonable interim mitigation.Also confirming the local workaround: setting
sandbox.enabled: falsefor the affected repo removes the wrapper and eliminates the failure (verified: nosandbox-execprocess is spawned at all).(All paths, usernames, repo/project names, proxy tokens, and the base64 LogTag content redacted; only structural facts and byte sizes shown.)
Measured data for this bug posted in https://github.com/anthropics/claude-code/issues/73437#issuecomment-4874412129 — bisected threshold on 2.1.199: 8 registered worktrees ✅ / 11 ❌, i.e. ~75 KB argv cost per worktree; static base profile alone >256 KB at zero worktrees.
Corroborating measurements from a heavy multi-worktree macOS setup (Darwin 25.x arm64, Claude Code 2.1.x, sandbox enabled).
The deny-path list scales roughly one filesystem deny entry per registered git worktree. Measurements across sessions on the same fleet (July 2026):
| observation | deny paths (total) | of which worktree-derived | inline profile / argv size |
|---|---|---|---|
| A | 231 | 204 | 1.2 MB |
| B | 272 | 219 | 1.3 MB |
| C | 320 | 267 | 1.5 MB |
| D | 404 | 351 | 1.8 MB |
Above roughly 1 MB every sandboxed Bash spawn fails with
E2BIG: argument list too long, posix_spawn '/bin/zsh', includingpwd, and the only in-session workaround is running commands with the sandbox disabled — i.e. the sandbox protection is effectively lost exactly on the machines that use worktrees the most.Two aggravating factors worth noting for prioritization:
A per-directory prefix rule (all these worktrees live under a small number of parent directories such as
<repo>/.claude/worktrees/), or passing the profile via a file instead of argv as proposed above, would both remove the linear scaling with worktree count.Additional data point from 2.1.220 (macOS, headless
claude -pwith an isolatedCLAUDE_CONFIG_DIR): we hit the same E2BIG with only 3 of 206 deny paths coming from registered git worktrees, so the message's suggested remediation (git worktree remove/prune) was capped at 3/206 for us — and/sandboxis unavailable in headless runs.From strings on the bundled binary, the dominant contributors in our case were:
/that unconditionally pushes ~13 deny entries per level (.claude/launch.json,workflows,routines,output-styles,scheduled_tasks.json,loop.md,.mcp.json,skills,commands,agents,hooks,settings.json,settings.local.json), repeated for each additional/workspace directory;Since each deny path also expands in SBPL to per-ancestor literal rules (unlink/create pairs), both the entry count and the per-entry expansion scale with path depth. Our failing runs had cwd + config home under macOS's default deep TMPDIR (
/var/folders/..., 9–10 components).Verified workaround: relocating the ephemeral config home and working dirs to shallow paths (
/tmp, 2–3 components) brought the profile back under ARG_MAX — every Bash spawn then succeeds with deny semantics intact (our read/write confinement probes are still denied).+1 for passing the profile via a temp file (
sandbox-exec -f) and/or deduplicating the ancestor literal rules.One thing worth checking on the argv side of this: the inline Seatbelt profile doesn't only grow with
.git/worktrees/*— it also derives write-deny paths from the registeredprojectsin~/.claude.json, and those entries are never removed when their directory is deleted. On a create/delete-heavy workflow they accumulate unbounded, so the profile can blow past the argv limit even in a repo with only a handful of live worktrees.Data point from a heavy-automation setup: ~1,250
projectsentries, ~92% pointing at directories that no longer existed (~1 MB of derived argv on their own); live worktrees were a minor contributor. Pruning the missing-dir entries immediately restored Bash.Since both sources emit deny-paths, a single guard covers them: skip any deny-path whose target directory no longer exists (one
isdir()filter before rule emission). Full measurements + rationale in my comment on the sibling issue: https://github.com/anthropics/claude-code/issues/73437#issuecomment-5095540627Corroborating measurements from CLI 2.1.219 (macOS arm64, Darwin 25.6) — two data points beyond the worktree-count scaling above:
1. Baseline regression 2.1.202 → 2.1.219. The generated profile's baseline (before any user-supplied rules) grew to roughly 700 KB across that window; each registered git worktree then adds ~20–25 KB. On a machine with a few dozen worktrees, an ordinary session's composed spawn already sits at ~98.9% of
ARG_MAX— so the failure no longer needs the ~1,000-worktree scale in the original report.2. User-supplied deny paths ride the same inline argument. A
settings.local.jsonsandboxblock whosefilesystem.denyWritecarries 246 paths pushes the single profile argument to 1.1 MB, and every Bash spawn fails before anything executes. The CLI's own diagnostic (2.1.219) confirms the budget is entirely the inline profile, not the environment:Writing the profile to a temp file (
sandbox-exec -f) instead of-p, or deduplicating subsumed rules before emission, would remove the ceiling for both the worktree-driven and the settings-driven cases.