[BUG] Absolute deny patterns like Read(/Users/*/.ssh/**) are joined to cwd, inflating the macOS sandbox profile — E2BIG with only 5 worktrees

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 0 comments · opened Jul 29, 2026

Summary

Two findings. (1) is a distinct input-normalization bug: absolute permissions.deny path
patterns that contain a glob are joined to the working directory, producing paths that can
never match anything while still costing full profile expansion. (2) is that the built-in
E2BIG diagnostic assumes worktrees are the cause and recommends accordingly, which is wrong
on an enterprise-managed configuration: there the deny list, not worktree count, is the
dominant term, and following the advice does not recover the session.

Related but not duplicates — both remain the right structural fixes and I am not re-filing them:

  • #73468 — profile passed inline via sandbox-exec -p collides with ARG_MAX
  • #73437 — ancestor deny rules are expanded without deduplication

Environment

  • Claude Code 2.1.220 (also reproduced on 2.1.218)
  • macOS, Darwin 25.5.0, arm64
  • getconf ARG_MAX1048576
  • Sandbox enabled by enterprise-managed settings (~/.claude/remote-settings.json, not

locally editable); sandbox.allowUnsandboxedCommands: true

  • cwd is 6 segments deep: /Users/<user>/src/github.com/<org>/<repo>

Bug 1: absolute glob deny patterns are joined to cwd

The managed config contains rules of this shape (representative, 24 of them):

Read(/Users/*/.ssh/**)
Read(/home/*/.ssh/**)

The resolved sandbox filesystem deny list reported for the session contains, verbatim:

/Users/<user>/src/github.com/<org>/<repo>/Users/*/.ssh
/Users/<user>/src/github.com/<org>/<repo>/home/*/.ssh

The leading / was ignored and the pattern was appended to cwd.

Consequences:

  1. Dead weight. <cwd>/Users/*/.ssh cannot match any file on this machine, but it is

emitted into the Seatbelt profile at full cost.

  1. Maximally expensive dead weight. Because it inherits the cwd prefix, the path is 6

segments deeper than the pattern the admin wrote. Combined with the ancestor expansion in
#73437 (2 ops × (1 + depth) rules, each restating the full path), a cwd-joined path costs
several times what the original absolute pattern would have.

  1. The rule may not be enforced as written. For each of the 12 secret locations in this

config the resolved list holds three paths — one usable (~/.ssh, contributed separately
by sandbox.filesystem.denyRead) and two unmatchable cwd-joined ones. I could not confirm
whether a correctly-resolved /Users/*/.ssh is also emitted, because the list shown to the
Bash tool is truncated. If it is not, then these managed rules silently protect nothing —
which would make this a security issue, not just a size issue.

Expected: a pattern beginning with / is absolute and must not be joined to cwd.
Secondarily, /home/* patterns are inert on macOS and could be dropped from the profile
rather than expanded.

Bug 2: the E2BIG diagnostic points at a remedy that cannot work

Verbatim error from this session — every Bash command fails, including echo:

Could not start /bin/zsh: the command line plus environment exceed the OS exec argument
limit (E2BIG). At spawn: command line 1MB across 3 args (largest single arg 1MB);
environment 9.1KB across 121 vars (largest: LS_COLORS at 1.5KB). The Bash sandbox profile
adds 178 filesystem deny paths to every command, 15 of them for registered git worktrees,
which grow this list without bound. From another terminal, remove worktrees you no longer
need (git worktree remove <path>; git worktree prune for already-deleted checkouts), then
restart Claude Code so the profile is rebuilt without them — or relax the Bash sandbox for
this session with /sandbox.

The message itself reports that only 15 of 178 deny paths are worktree-related (5 worktrees × 3
paths — config.worktree, config.worktree.lock, commondir), and then offers removing
worktrees as the only remediation. Reconstructing the ancestor expansion over the observed deny
list puts worktree paths at roughly 11% of profile bytes — above their 8% share of paths, since
they are long and deep — but removing every one of them still does not get under the limit.

Looking at how the message is assembled in 2.1.220, the advice is gated only on the worktree
count being non-zero, never on its share:

u += ` The Bash sandbox profile adds ${n.length} filesystem deny paths to every command`;
u += d > 0
  ? `, ${d} of them for registered git worktrees, which grow this list without bound. From
     another terminal, remove worktrees you no longer need (...), then restart Claude Code ...
     — or relax the Bash sandbox for this session with /sandbox.`
  : ".";

So a single registered worktree is enough to make "remove worktrees" the entire recommendation,
even when — as here — worktrees are 8% of the deny paths and the managed deny list is the rest.

The fallback suggestion is unavailable too: /sandbox refuses with "Sandbox settings are
overridden by a higher-priority configuration", because the managed config sets
sandbox.enabled. For an enterprise-managed user both suggested remedies are dead ends, and
the only escape is per-command dangerouslyDisableSandbox.

Asks:

  • Gate the worktree advice on d being a material share of n, not on d > 0.
  • When it is not, name the actual top contributor instead — e.g. "N of these come from managed

settings; cwd is D segments deep, which multiplies every relative rule".

  • Don't offer /sandbox when a higher-priority configuration will reject it.

Why this configuration reaches the ceiling without many worktrees

To be upfront: the managed policy on this machine is probably on the extreme end, and I am
separately asking our own policy owners to trim it. I am not asking you to fix our policy. The
reason it is worth reporting is that this is a real, deployed enterprise configuration, pushed
from the Console, that end users cannot edit — and it is what made the bugs above surface as
"every Bash command fails, permanently, with no working remedy". Profile size scales with an
input the affected user does not control, so the failure mode matters more than the input.

What the config looks like:

  • 150 Read()/Edit() deny rules over 93 distinct path patterns, from

~/.claude/remote-settings.json.

  • 57 of the 150 are the same path listed under both Read() and Edit().
  • Each secret pattern appears in three variants — *.pem, **/*.pem, //**/*.pem — and each

variant becomes its own deny path with its own full ancestor expansion.

  • 24 of the patterns are the absolute globs from Bug 1, so they are expanded at cwd depth.
  • Checkout depth multiplies everything: cwd-relative patterns sit 6 segments deep here.
  • Result at spawn time: 178 deny paths, ~1 MB profile, against a 1,048,576-byte ARG_MAX.

A config half this size is still within a plausible range for a security-conscious org, so the
headroom here is thin for anyone, not just us. And 2.1.220 still fails at 5 worktrees on this
configuration — i.e. after the 2.1.203 worktree-repo change referenced in #75450 — which is why
worktree-side fixes alone do not close this out.

Suggested fixes, by impact

  1. Write the profile to a temp file and use sandbox-exec -f <file> (or sandbox_init) —

removes the ceiling entirely (#73468).

  1. Deduplicate ancestor literals (#73437).
  2. Do not cwd-join deny patterns that are already absolute (this issue).
  3. Deduplicate identical resolved paths across the read and write deny lists before expansion.
  4. If the assembled argv would exceed ARG_MAX, fall back to a file-based profile instead of

failing every Bash command for the rest of the session.

Repro for Bug 1

  1. macOS, sandbox enabled.
  2. Add Read(/Users/*/.ssh/**) to permissions.deny.
  3. Start Claude Code with cwd several levels deep.
  4. The generated profile contains rules for <cwd>/Users/*/.ssh. From outside the binary this

is observable in the resolved sandbox config surfaced to the Bash tool, which lists the
cwd-joined form.

Paths, usernames and repository names above are placeholders; counts and the quoted error are
from the affected machine.

---

Investigated with Claude Code's help: the string extraction from the 2.1.220 bundle, the deny
list analysis and this write-up were produced in a Claude Code session, on the affected machine.
Every number and quoted string above was read off that machine rather than inferred.

View original on GitHub ↗