macOS sandbox unusable: Seatbelt profile passed inline via 'sandbox-exec -p' exceeds ARG_MAX with many git worktrees

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

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

  1. 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.
  2. With the sandbox enabled, run any Bash command, e.g. printf ok.
  3. 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_MAX1048576
  • Environment size → ~12 KB (not the cause)
  • Sandbox disabled → all commands work
  • Sandbox enabled → even printf ok fails
  • Reconstructing the profile from git state confirms the file-write deny rules are the dominant term and scale with worktree/repo count

Suggested fixes

  1. Primary: write the profile to a temp file and use sandbox-exec -f <file> instead of -p <inline>. File-based profiles have no ARG_MAX limit and this is a minimal change.
  2. Alternative: use the in-process sandbox_init(3) API (already referenced in the binary) rather than exec-ing sandbox-exec with the profile in argv.
  3. 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.
  4. 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 raw E2BIG).

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.

View original on GitHub ↗

6 Comments

watsonix · 1 month ago

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-exec invocation)

I shadowed env earlier in PATH with a passthrough that logs argv/envp byte sizes when the args contain sandbox-exec, then exec /usr/bin/env "$@". The harness's env … /usr/bin/sandbox-exec -p … /bin/zsh -c … hits it. From an affected session:

argc = 30
argv_bytes = 930,073
  └─ the inline  -p  profile arg  =  928,304 bytes   (≈ 906 KB, a SINGLE argv element)
  └─ ~1.4 KB of proxy env args (HTTP_PROXY/NO_PROXY/GIT_SSH_COMMAND/…)
envp_bytes = ~2,300
TOTAL      = 932,348 bytes        vs  ARG_MAX = 1,048,576 (1 MB)

So the profile alone is ~906 KB and the whole spawn is at ~89% of ARG_MAX at the repo root; a worktree-launched session adds more enumerated paths and tips it over 1 MB → E2BIG before 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 in argv vs ARG_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 from argv entirely and makes profile size irrelevant to ARG_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 -f change and a reasonable interim mitigation.

Also confirming the local workaround: setting sandbox.enabled: false for the affected repo removes the wrapper and eliminates the failure (verified: no sandbox-exec process 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.)

switchbm · 1 month ago

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.

soichisumi · 1 month ago

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', including pwd, 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:

  1. Agent-driven workflows create ephemeral worktrees (subagent isolation, workflow runs) at a rate of roughly 13-17 per day per active repo, so manual pruning only buys a few days before E2BIG returns.
  2. The profile is rebuilt only at session start, so pruning worktrees mid-session does not recover the session; a restart is required.

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.

soichisumi · 1 month ago

Additional data point from 2.1.220 (macOS, headless claude -p with an isolated CLAUDE_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 /sandbox is unavailable in headless runs.

From strings on the bundled binary, the dominant contributors in our case were:

  • an ancestor-directory walk from cwd up to / 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;
  • ~18 fixed entries under the Claude home;
  • worktrees visited via EnterWorktree accumulate in an in-memory map that we could not find a removal path for on ExitWorktree (relevant to the "grow this list without bound" wording — the growth is broader than registered-worktree count).

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.

zenprocess · 1 month ago

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 registered projects in ~/.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 projects entries, ~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-5095540627

vitkl · 29 days ago

Corroborating 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.json sandbox block whose filesystem.denyWrite carries 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:

/opt/homebrew/bin/bash: 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 3.6KB across 72 vars (largest: PATH at 711 bytes). The Bash sandbox profile adds 246 filesystem deny paths to every command.

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.

Showing cached comments. Read the full discussion on GitHub ↗