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

Open 💬 0 comments Opened Jul 4, 2026 by LN405Rx

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-net builds a distinct net namespace and blocks non-allowlisted hosts)
  • socat installed
  • Claude Code with sandbox.enabled: true

Repro

  1. 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
``

  1. 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 }
}
``

  1. 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, and execve returns E2BIG when 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 hard E2BIG rather than a graceful degrade. Setting failIfUnavailable: true in this state would brick all Bash execution (including CI).

Proposed fixes (any one resolves it)

  1. 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.
  2. Pass binds out-of-band. Use bwrap --args <fd> (newline/NUL-delimited args on a file descriptor) instead of inline argv, removing the ARG_MAX/MAX_ARG_STRLEN ceiling entirely.
  3. Coalesce overlapping denies. When an explicit sandbox.filesystem.denyRead directory subsumes a permission Read(dir/**) glob, dedupe so the per-file expansion is dropped — this would at least make the directory-level workaround effective.
  4. Guardrail. Detect when the generated bind argv would exceed MAX_ARG_STRLEN/ARG_MAX and emit a clear diagnostic (which glob expanded to how many paths) instead of a bare E2BIG.

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

  1. 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

  1. In .claude/settings.json, deny reads on that tree:

{ "permissions": { "deny": ["Read(data/)", "Read(/*.parquet)"] }, "sandbox": { "enabled": true } }

  1. 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.

View original on GitHub ↗