[BUG] Linux sandbox broken - bad bwrap calls and no allow permissions
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?
When sandboxing is enabled on Linux, claude uses bwrap but passes the wrong flags for various reasons. The result is:
- Unable to allow writing to extra directories.
- Extra 0-length files created in the directory.
- If deny rules for directories are used (depending on syntax?) all sandboxed bash calls fail due to invalid
bwraparguments.
CC ignores all allow Edit(...) rules and only handles deny rules. This makes it impossible to make useful directories like /tmp, .cache and .m2 writeable when using the sandbox.
CC doesn't check whether a file exists before trying to mask it by mounting /dev/null to the path of the file or directory. That results in a zero-byte file being created with that name, so the directory gets trashed with lots of files, both the default .profile etc CC chooses to hide plus any custom deny rules.
CC doesn't handle directories properly. If I'm not mistaken, for foo/** if foo doesn't exist, it first mounts /dev/null to it, create a 0 length file. On the subsequent invocations, it realises the file is there and instead mounts /dev/null to foo/** (_with_ the asterisks which is bad bwrap syntax, causing an error). It should probably use --tmpfs for directories.
{
"permissions": {
"allow": [
"Edit(testallow)",
"Edit(testallowdir/**)",
"Edit(./testallowdot)",
"Edit(./testallowdotdir/**)",
"Edit(~/testallowtilde)",
"Edit(~/testallowtildedir/**)",
"Edit(//tmp/testallow)",
"Edit(//tmp/testallowdir/**)"
],
"deny": [
"Edit(testdeny)",
"Edit(testdenydir/**)",
"Edit(./testdenydot)",
"Edit(./testdenydotdir/**)",
"Edit(~/testdenytilde)",
"Edit(~/testdenytildedir/**)",
"Edit(//tmp/claude/testdeny)",
"Edit(//tmp/claude/testdenydir/**)"
]
}
}
(First call, before 0-length files are created)
--ro-bind / <same>
--bind /tmp/claude <same>
--bind /home/abcde/.npm/_logs <same>
--bind /home/abcde/.claude/debug <same>
--bind /home/abcde/dev/bwrap-claude-test <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.claude/settings.json <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.claude/settings.local.json <same>
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/testdeny
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/testdenydir
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/testdenydot
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/testdenydotdir
--ro-bind /dev/null /tmp/claude/testdeny
--ro-bind /dev/null /tmp/claude/testdenydir
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.gitconfig
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.gitmodules
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.bashrc
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.bash_profile
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.zshrc
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.zprofile
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.profile
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.ripgreprc
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.mcp.json
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.vscode
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.idea
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.claude/commands
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/.claude/agents
--ro-bind /home/abcde/dev/bwrap-claude-test/.git/hooks <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.git/config <same>
(Flags on subsequent calls)
--ro-bind / <same>
--bind /tmp/claude <same>
--bind /home/abcde/.npm/_logs <same>
--bind /home/abcde/.claude/debug <same>
--bind /home/abcde/dev/bwrap-claude-test <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.claude/settings.json <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.claude/settings.local.json <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/testdeny <same>
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/testdenydir/**
--ro-bind /home/abcde/dev/bwrap-claude-test/testdenydot <same>
--ro-bind /dev/null /home/abcde/dev/bwrap-claude-test/testdenydotdir/**
--ro-bind /tmp/claude/testdeny <same>
--ro-bind /dev/null /tmp/claude/testdenydir/**
--ro-bind /home/abcde/dev/bwrap-claude-test/.gitconfig <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.gitmodules <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.bashrc <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.bash_profile <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.zshrc <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.zprofile <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.profile <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.ripgreprc <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.mcp.json <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.vscode <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.idea <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.claude/commands <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.claude/agents <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.git/hooks <same>
--ro-bind /home/abcde/dev/bwrap-claude-test/.git/config <same>
flags-on-first-call.txt
flags-on-second-call.txt
claude-settings.json
What Should Happen?
Claude Code should invoke bwrap with correct arguments.
Steps to Reproduce
- Use Linux.
- Edit permission settings.
- Enable sandbox.
- Get Bash tool to write to allowed directory.
- Observe failure.
Alternatively:
- Put a
bwrapscript on your path that logs its calls before forwarding to the realbwrap. - Edit permission settings.
- Check logs.
- Etc.
Claude Model
Not sure / Multiple models
Is this a regression?
No, this never worked
Last Working Version
_No response_
Claude Code Version
2.1.5
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux
Terminal/Shell
Other
Additional Information
This includes the bug about dotfiles but is broader.
17 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
I'm giving a thumbs down for "not a duplicate" though those bugs are related to this one. This report is broader and covers the configuration of
bwrapmore generally, which doesn't respectallowrules, or check file existence, or handle directories properly.Still reproducing on Claude Code 2.1.47 — additional findings
Environment: Claude Code 2.1.47, GitHub Codespaces devcontainer, Linux 6.8.0-1030-azure x86_64, sandbox enabled (bwrap)
The phantom file creation is still happening. Bumping this since it was marked stale.
Reproduction
Fresh session in a devcontainer. After any Bash tool call, git status shows these untracked files that did not exist before:
?? .bash_profile
?? .bashrc
?? .profile
?? .zprofile
?? .zshrc
?? .gitconfig
?? .gitmodules
?? .ripgreprc
?? .claude/agents
?? .claude/commands
?? .idea
?? .vscode
?? HEAD
?? config
?? hooks
?? objects
?? refs
All are 0-byte, read-only files created by bwrap bind-mount placeholders.
New finding: phantom files bypass .gitignore directory patterns
.vscode/ and .idea/ are in .gitignore with trailing slashes (directory-only match). The sandbox creates .vscode and .idea as empty files, not directories, so the gitignore rules don't match and they show as untracked. Same for .claude/agents and .claude/commands.
$ file .vscode .idea .claude/agents .claude/commands
.vscode: empty
.idea: empty
.claude/agents: empty
.claude/commands: empty
#17087 closed but not fully resolved
#17087 was closed as completed (Feb 6), but the dotfile phantom files still reproduce on 2.1.47. The sandbox-runtime PRs (#91, #126) may not have propagated to the bundled runtime yet, or they only
addressed a subset of the paths.
Git internals still leaking
The denyWithinAllow auto-generation still drops the .git/ prefix, creating HEAD, config, hooks, objects, refs at the project root instead of protecting .git/HEAD, .git/config, etc.
Workaround
Added rooted patterns to .gitignore:
/HEAD
/config
/hooks
/objects
/refs
/.bash_profile
/.bashrc
/.profile
/.zprofile
/.zshrc
/.gitconfig
/.gitmodules
/.ripgreprc
/.claude/agents
/.claude/commands
/.idea
/.vscode
🤖 Generated with Claude <noreply@anthropic.com>
I have the same issue, v2.1.59.
I've have to
.gitignorefiles in all of my projects due to this; is there a better workaround besides that or not using the sandbox?The denyWithinAllow path matching also breaks the EnterWorktree tool in Claude Code v2.1.52 — .git/worktrees/<name>/HEAD is blocked because HEAD matches the deny suffix. No negation syntax available. Related: #17374, #29316.
🤖 Generated with Claude noreply@anthropic.com
+1 — Same behavior on Linux (WSL2, kernel 6.6.87.2-microsoft-standard-WSL2).
sandbox.filesystem.allowWritepaths work for Write/Edit tools but not for Bash commands (rm,mv, etc.) which getRead-only file system. Additionally,permissions.allowentries likeWrite(.vscode/**)still prompt.Settings example:
Observed:
Write(.vscode/_test.txt)→ prompts (should be auto-allowed)Write(.claude/_test.txt)→ prompts with "allow Claude to edit its own settings"Bash(rm .vscode/_test.txt)→Read-only file systemStill the same issue in 2.1.79.
I was running into a bunch of issues. Claude suggested this and it worked:
Here are some explanations, also Claude-generated:
Bump so stale bots won't kill it. Still an issue(s) in 2.1.92 (Claude Code)
Still present in v2.1.97 — here's the exact code path from a binary strings analysis.
Reproducing the issue on Linux (Ubuntu 24.04, Claude Code
2.1.97, sandbox enabled withautoAllowBashIfSandboxed: true). In my case the phantom entries in the project root are character device nodes, not 0-byte files:Major/minor
1, 3is/dev/null, andnogroupis the user-namespace overflow GID. So these aren't stray stubs — they're literal/dev/nulldevice nodes created on the host bybwrap'smknodwhen it prepares a bind-mount target to match the source file type. Since the target paths end up inside the writable project bind-mount (--bind $PROJECT $PROJECT), themknodpropagates straight through to the host filesystem. Cleanup is best-effort and skipped when other sandboxes are active (Deferring mount point cleanup — N sandbox(es) still active), so they persist across sessions.Root cause
The bug is in the function that builds the auto-deny list, minified as
Rx4:with these hardcoded lists:
The intent is clearly to deny reads of home-directory dotfiles (
~/.bashrc,~/.gitconfig, etc.), butpath.resolve(process.cwd(), ".bashrc")produces$PROJECT/.bashrc. Downstream, the sandbox walks up to find an existing ancestor, notices the project dir is a write-allowed region, and emits:which is where the char-device node gets created.
Fix
One-liner:
Arguably the whole auto-list should be optional, since users can express the same intent through explicit
denyReadentries insettings.local.json. But just correcting the base path eliminates the phantom device nodes without changing the protection semantics for the home-dir dotfiles.The hardcoded deny on writing to
.gitalso preventsCPM.cmake/ CMake's ownFetchContentfrom working properly. Please fix it! I really want to use sandboxed worktrees but that's currently impossible unless opting for out-of-dir builds in/tmp.Confirming this still reproduces on v2.1.120 (released today, 2026-04-25) on Ubuntu 24.04 LTS — and the AppArmor profile fix from @3f6a's comment (2026-03-27) still works.
Environment:
kernel.apparmor_restrict_unprivileged_userns: 1 (Ubuntu 24.04 default)kernel.unprivileged_userns_clone: 1Symptom on startup:
(The unrendered
${j$}template literal is a separate UX bug — I've filed it as a focused issue: #53081.)Confirming @3f6a's recipe still works in v2.1.120:
After applying,
bwrap --bind / / /bin/echo okruns successfully as the unprivileged user and Claude Code starts cleanly.Worth flagging that v2.1.98+ tightened sandbox enforcement so this is now a startup-blocker on Ubuntu 24.04, not a runtime degradation — first-time users on a fresh Ubuntu 24.04 box with no prior bubblewrap setup will hit a hard wall. Two things would massively improve the UX here:
Happy to PR docs if useful.
Another
bwrapfailure mode in this bucket: per-path bind args overflowARG_MAXwhen launched from a large-tree cwd → every sandboxed Bash call dies withE2BIGConfirming on 2.1.177 / Ubuntu, with a controlled repro that pins the cause for the "all sandboxed bash calls fail due to invalid bwrap arguments" symptom — at least one variant of it is cwd-file-count dependent.
Because the sandbox is rebuilt per-command and emits a separate bind/mask argument per resolved path under the writable root, the assembled
bwrapargv grows with the size of the cwd tree. Past a point it exceeds the kernel'sARG_MAX, andposix_spawn('/bin/bash')fails before the shell ever runs:Minimal A/B — identical command, identical settings (
sandbox.enabled: true), only the launch directory differs:A 6-byte command (
echo OK) fails identically to a long one, so the overflow is entirely in the wrapper's argv, not the user command. Binary strings analysis lines up with the earlier findings in this thread — the builder processes write/read paths individually ([Sandbox Linux] Processing write path: …,Skipping symlink write path pointing outside expected location: …), i.e. one--ro-bind/mask arg per path rather than binding a subtree once.Suggested fixes (any one):
--args <fd>file/descriptor to sidestep theargv/ARG_MAXceiling.E2BIG.Workarounds today: launch
claudefrom a small, focused project directory rather than$HOME; or disable the sandbox for the affected call. (Related but distinct from the AppArmor-profile fix above — this one reproduces even when bwrap is otherwise able to run.)Follow-up to my earlier cwd-file-count comment — a second, distinct overflow path in the same bucket, with a precise root cause + a config-only fix that works.
On 2.1.186 / Ubuntu the sandbox became unusable again: every sandboxed Bash call died with
E2BIG: argument list too long, posix_spawn '/bin/bash'before the shell ran. This one is not cwd-tree-size; it'spermissions.denyglob-expansion hitting the per-argument limit.Root cause (from the bundled sandbox builder
nFi+ live measurement)permissions.denyRead(...)rules into the bwrap filesystem deny set and glob-expands each rule into one--ro-bind /dev/null <path>per matching file on disk.bwrap+ every bind +-- /bin/bash -c <usercmd>— into a single shell-quoted string, and runs that string asbash -c "<P>".MAX_ARG_STRLEN(128 KiB, the per-single-argument cap), not the 2 MiB totalARG_MAX. One over-broad glob is enough.A common, innocent rule triggers it:
**/*.pemexpanded to 887 files on my box — none of them secrets: ~304 Flatpak-runtime CA bundles, ~430 system CA certs under/etc/ssl,/usr/lib/ssl, ~82 Cargoopenssl/jsonwebtokentest fixtures, plus uv/pipx venv certs. That serialized to ~141 KB as a singlebash -cargument →posix_spawn('/bin/bash')→E2BIG, regardless of cwd.Minimal measurement (reproduces the kernel error directly)
Suggested fixes (any one resolves it; defense-in-depth = all three)
--args <fd>(NUL-separated, read from a pipe) instead of one giantbash -cstring — removes the single-arg ceiling entirely. This is the real fix.**/*.pemshould be enforced as a subtree/regex rule, not materialized into hundreds of concrete--ro-bindargs.allowUnsandboxedCommands: true, catchE2BIGand fall back to unsandboxed (or surface a clear "deny list too large" diagnostic) instead of hard-failing every Bash spawn silently.User-side workaround
Avoid broad on-disk-matching globs (
Read(**/*.pem),Read(**/*.key), …) inpermissions.deny— each expands to one bind per matching file. Use directory-scoped rules (Read(~/.ssh/**),Read(~/.config/<tool>/**)) instead; those add ~1 bind each and also work as the sandboxdenyRead(which masks the dir from in-sandbox Bash too). Replacing the blanket**/*.pemwith three dir-scoped rules took my serialized argv from ~141 KB to ~6 KB and restored the sandbox with no loss of real-secret coverage.I wish I could go back safely to OpenCode without risking my account banned. Claude Code is just bad, they don't address issues, unless you know someone to hit via private channels, or when it blows up on social media. Pointless to report issues or care at this point.
Same root cause, but a hard crash (not just phantom files) when the workspace is on a restricted filesystem (virtiofs / microVM)
Adding a data point that isn't yet in this thread: the failure mode flips from "annoying phantom nodes" to "every sandboxed Bash call dies" depending on the filesystem backing the workspace.
Environment: Claude Code running inside a microVM sandbox where the project workspace is a virtiofs mount backed by a Windows/NTFS host.
On a normal
ext4workspace, the cwd-resolved deny-list (theRx4/oiH/kx4bug pinned by @poltimmer above) produces--ro-bind /dev/null <project>/.bashrcetc. that succeed — bwrapmknods a char-device/0-byte placeholder. Cosmetic pollution, shell still runs.On virtiofs-over-NTFS, that target-creation/
mknodisn't supported, so the same bind fails outright:Result: every Bash tool call aborts before the command runs. Same root cause as the phantom-file reports, but a hard blocker instead of a nuisance — and
.gitignoreworkarounds don't help, because nothing pollutes; the calls just die.It also presents as intermittent because bwrap is fail-fast (reports only the first missing target) and the set of present targets shifts mid-session — e.g.
.claude/workflowsappears once a skill runs,.claude/routines/.claude/scheduled_tasks.jsononce scheduled tasks/loops run — so each failure names a different file.Full deny-target set observed (workspace-relative, via a
bwrapargv-logging shim on PATH):.claude/{hooks,launch.json,workflows,routines,scheduled_tasks.json,agents},.gitconfig .bashrc .bash_profile .zshrc .zprofile .profile .ripgreprc,.idea .vscode.Workaround that actually works on this filesystem: pre-create the targets at session start (empty file for the file entries, real dirs for
.idea/.vscode/.claude/*) so bwrap binds over an existing inode instead of trying to create one. That avoids both the crash and the phantom nodes..gitignorealone is insufficient here.This strengthens the case for the suggested fixes beyond the one-line
cwd → homedircorrection: on restricted filesystems the per-path target creation itself is impossible, so binding the writable root as a single subtree / passing binds via--args <fd>/ not materializing one--ro-bindper path would also sidestep this class entirely.Still reproduces; the underlying deny-list code path is unchanged.