Linux sandbox: recursive Read() deny globs expand to per-file bwrap binds → E2BIG on every command (incl. echo hello) when a denied directory holds many files
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Summary
On Linux, when sandbox.enabled: true, the sandbox derives its bubblewrap (bwrap) filesystem binds by expanding recursive permissions.deny Read(...) globs into one bind per matched file. If a denied directory contains many files, the generated argument list overflows the kernel's single-argument limit (MAX_ARG_STRLEN, 128 KB) and every sandboxed command — down to echo hello — fails at spawn time with:
E2BIG: argument list too long, posix_spawn '/bin/bash'
The sandbox becomes completely unusable: no command can run inside it. With sandbox.enabled: false the identical command runs fine.
Environment
- OS: Linux 6.6 (WSL2)
bwrap(bubblewrap) 0.9.0 at/usr/bin/bwrap— functionally capable (bwrap --unshare-netbuilds a distinct net namespace and blocks non-allowlisted hosts)socatinstalled- Claude Code with
sandbox.enabled: true
Repro
- Create a repo with a data directory containing a few thousand files:
``bash``
mkdir -p data/marts && for i in $(seq 1 3552); do : > "data/marts/part_$i.parquet"; done
- In
.claude/settings.json, deny reads on that tree (a normal PHI/secret-protection config):
``json``
{
"permissions": {
"deny": ["Read(data/**)", "Read(**/*.parquet)"]
},
"sandbox": { "enabled": true }
}
- Run any Bash command through Claude Code (e.g.
echo hello).
Expected: hello (command runs inside the sandbox).
Actual: E2BIG: argument list too long, posix_spawn '/bin/bash' — for every command.
Root cause / quantification
The deny globs are expanded to concrete per-file paths at bind time, not bound as a single directory. In the case that surfaced this:
- Denied directory: 3,552 files (1,311
.parquet) - Concatenated path strings: ~368 KB
getconf ARG_MAX= 2,097,152 (2 MB) — the total limit is not the binding constraint here- Linux
MAX_ARG_STRLEN= 131,072 bytes (128 KB) is the per-argument limit, andexecvereturnsE2BIGwhen any single argument exceeds it
368 KB of path data exceeds the 128 KB single-arg limit (and, for larger trees, would also approach ARG_MAX), so the spawn fails before the command runs. The number of files, not their contents, is what breaks it.
Merge-semantics finding (why the obvious workaround fails)
The docs state that sandbox.filesystem entries and permission rules are merged ("Paths from both sandbox.filesystem settings and permission rules are merged together"), i.e. sandbox.filesystem supplements rather than replaces the permission-derived denies.
I verified this: adding a directory-level deny while leaving the permission glob in place —
"sandbox": { "enabled": true, "filesystem": { "denyRead": ["data"] } }
— did not clear the E2BIG, because Read(data/**) still expands to 3,552 per-file binds and the directory deny is merely added to the set. There is currently no way to keep the recursive Read() protection and avoid the per-file expansion.
Impact
- Any repo that denies reads on a large data/secret tree (a common and recommended hardening pattern) gets a sandbox that fails closed-but-broken: 100% of commands error.
- On Linux the fallback is silent-unsandboxed unless
sandbox.failIfUnavailable: true— but here the sandbox is "available," it just produces an un-spawnable argv, so the failure is a hardE2BIGrather than a graceful degrade. SettingfailIfUnavailable: truein this state would brick all Bash execution (including CI).
Proposed fixes (any one resolves it)
- Bind denied directories once. When a
Read()glob is directory-recursive (dir/**), bind the directory prefix a single time instead of enumerating its files. This collapses thousands of args to one. - Pass binds out-of-band. Use
bwrap --args <fd>(newline/NUL-delimited args on a file descriptor) instead of inlineargv, removing theARG_MAX/MAX_ARG_STRLENceiling entirely. - Coalesce overlapping denies. When an explicit
sandbox.filesystem.denyReaddirectory subsumes a permissionRead(dir/**)glob, dedupe so the per-file expansion is dropped — this would at least make the directory-level workaround effective. - Guardrail. Detect when the generated bind argv would exceed
MAX_ARG_STRLEN/ARG_MAXand emit a clear diagnostic (which glob expanded to how many paths) instead of a bareE2BIG.
Fix (1) or (2) is the real solution; (3) makes a workaround available in the meantime.
Related issues (all stale-closed, none fixed)
Previously diagnosed in #46461 (per-file bwrap expansion, ARG_MAX) and #51126
(single bash -c string exceeding MAX_ARG_STRLEN 128KB) — both auto-closed for
inactivity with "open a new issue if this is still relevant." It is: still
present in 2.1.201. My case (~368KB expanded paths in the single -c argument)
is #51126's mechanism driven by #46461's expansion. Same root cause as #46255
and #27757.
What Should Happen?
The sandboxed command runs normally (echo hello prints hello). The size of the deny-list should not affect the sandbox's ability to spawn commands — directory-recursive Read(dir/**) deny globs should be bound as a single directory rather than enumerated per-file.
Error Messages/Logs
E2BIG: argument list too long, posix_spawn '/bin/bash'
Steps to Reproduce
- Create a repo with a data directory containing a few thousand files:
mkdir -p data/marts && for i in $(seq 1 3552); do : > "data/marts/part_$i.parquet"; done
- In .claude/settings.json, deny reads on that tree:
{ "permissions": { "deny": ["Read(data/)", "Read(/*.parquet)"] }, "sandbox": { "enabled": true } }
- Run any Bash command through Claude Code (e.g.
echo hello).
Expected: hello. Actual: E2BIG: argument list too long, posix_spawn '/bin/bash' — for every command.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.201
Platform
Other
Operating System
Windows
Terminal/Shell
WSL (Windows Subsystem for Linux)
Additional Information
bwrap and socat verified independently functional on this host (bwrap --unshare-net builds an isolated namespace and blocks egress) — the failure is specific to the harness-generated bind argv.