[BUG] Linux sandbox broken - bad bwrap calls and no allow permissions

Open 💬 17 comments Opened Jan 12, 2026 by jancellor

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 bwrap arguments.

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

  1. Use Linux.
  2. Edit permission settings.
  3. Enable sandbox.
  4. Get Bash tool to write to allowed directory.
  5. Observe failure.

Alternatively:

  1. Put a bwrap script on your path that logs its calls before forwarding to the real bwrap.
  2. Edit permission settings.
  3. Check logs.
  4. 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.

View original on GitHub ↗

17 Comments

github-actions[bot] · 6 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/17087
  2. https://github.com/anthropics/claude-code/issues/17258
  3. https://github.com/anthropics/claude-code/issues/16626

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

jancellor · 6 months ago

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 bwrap more generally, which doesn't respect allow rules, or check file existence, or handle directories properly.

qte77 · 4 months ago

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>

AgainPsychoX · 4 months ago

I have the same issue, v2.1.59.

ixxie · 4 months ago

I've have to .gitignore files in all of my projects due to this; is there a better workaround besides that or not using the sandbox?

qte77 · 4 months ago

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

dfaivre-pcs · 4 months ago

+1 — Same behavior on Linux (WSL2, kernel 6.6.87.2-microsoft-standard-WSL2).

sandbox.filesystem.allowWrite paths work for Write/Edit tools but not for Bash commands (rm, mv, etc.) which get Read-only file system. Additionally, permissions.allow entries like Write(.vscode/**) still prompt.

Settings example:

{
  "sandbox": {
    "enabled": true,
    "autoAllowBashIfSandboxed": true,
    "filesystem": {
      "allowWrite": [
        "~/.npm/**",
        "/tmp/claude-1000/**",
        "/tmp/claude/**",
        ".vscode/**",
        ".claude/**"
      ]
    }
  },
  "permissions": {
    "allow": [
      "Edit(.claude/**)",
      "Edit(.vscode/**)",
      "Write(.claude/**)",
      "Write(.vscode/**)"
    ]
  }
}

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 system
AgainPsychoX · 4 months ago

Still the same issue in 2.1.79.

3f6a · 3 months ago

I was running into a bunch of issues. Claude suggested this and it worked:

sudo tee /etc/apparmor.d/bwrap <<'EOF'
abi <abi/4.0>,
include <tunables/global>
profile bwrap /usr/bin/bwrap flags=(unconfined) {
  userns,
}
EOF

sudo apparmor_parser -r /etc/apparmor.d/bwrap

Here are some explanations, also Claude-generated:

The kernel of truth: Ubuntu 24.04 did indeed ship with apparmor_restrict_unprivileged_userns=1 by default. This is a real change that broke several tools that rely on unprivileged user namespaces — Flatpak, Electron apps, and yes, bubblewrap (bwrap). It's a well-documented issue. The logic chain: If Claude Code uses bwrap for sandboxing on Linux, then bwrap needs to create a user namespace, which AppArmor now blocks unless the binary has an explicit profile granting userns. That would mean the sandbox itself can't start — which is a completely different claim from "Julia needs loopback networking." It also means this isn't Julia-specific at all.
AgainPsychoX · 3 months ago

Bump so stale bots won't kill it. Still an issue(s) in 2.1.92 (Claude Code)

poltimmer · 3 months ago

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 with autoAllowBashIfSandboxed: true). In my case the phantom entries in the project root are character device nodes, not 0-byte files:

$ ls -la .bashrc .zshrc .gitconfig .idea .vscode .claude/commands
crw-rw-rw- 1 nogroup 1, 3 Mar 18 03:13 .bashrc
crw-rw-rw- 1 nogroup 1, 3 Mar 18 03:13 .zshrc
crw-rw-rw- 1 nogroup 1, 3 Mar 18 03:13 .gitconfig
crw-rw-rw- 1 nogroup 1, 3 Mar 18 03:13 .idea
crw-rw-rw- 1 nogroup 1, 3 Mar 18 03:13 .vscode
crw-rw-rw- 1 nogroup 1, 3 Mar 18 03:13 .claude/commands

Major/minor 1, 3 is /dev/null, and nogroup is the user-namespace overflow GID. So these aren't stray stubs — they're literal /dev/null device nodes created on the host by bwrap's mknod when 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), the mknod propagates 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:

async function Rx4(H={command:"rg"},$=OI8,q=!1,K){
  let _ = process.cwd(),
      ...
      O = [
        ...oiH.map((j) => path.resolve(_, j)),    // ← bug
        ...z.map((j)  => path.resolve(_, j)),     // ← bug
      ],
      Y = path.resolve(_, ".git"),
      ...
}

with these hardcoded lists:

oiH = [".gitconfig", ".gitmodules", ".bashrc", ".bash_profile",
       ".zshrc", ".zprofile", ".profile", ".ripgreprc",
       ".mcp.json", ".claude.json"];

PN$ = () => [...kx4.filter(h => h !== ".git"),
             ".claude/commands", ".claude/agents"];
kx4 = [".git", ".vscode", ".idea"];

The intent is clearly to deny reads of home-directory dotfiles (~/.bashrc, ~/.gitconfig, etc.), but path.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:

--ro-bind /dev/null /abs/path/to/project/.bashrc

which is where the char-device node gets created.

Fix

One-liner:

- O = [
-   ...oiH.map((j) => path.resolve(_, j)),
-   ...z.map((j)  => path.resolve(_, j)),
- ]
+ const home = os.homedir();
+ O = [
+   ...oiH.map((j) => path.resolve(home, j)),
+   ...z.map((j)  => path.resolve(home, j)),
+ ]

Arguably the whole auto-list should be optional, since users can express the same intent through explicit denyRead entries in settings.local.json. But just correcting the base path eliminates the phantom device nodes without changing the protection semantics for the home-dir dotfiles.

danra · 3 months ago

The hardcoded deny on writing to .git also prevents CPM.cmake / CMake's own FetchContent from 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.

nathanschram · 2 months ago

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:

  • Claude Code: 2.1.120 (auto-updated this morning, broke a working install)
  • OS: Ubuntu 24.04 LTS
  • Kernel: 6.8.0-110-generic
  • bubblewrap: 0.9.0 (from apt)
  • kernel.apparmor_restrict_unprivileged_userns: 1 (Ubuntu 24.04 default)
  • kernel.unprivileged_userns_clone: 1
  • AppArmor LSM: enabled

Symptom on startup:

Error: sandbox required but unavailable: ${j$}
sandbox.failIfUnavailable is set — refusing to start without a working sandbox.

(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:

sudo tee /etc/apparmor.d/bwrap <<'EOF'
abi <abi/4.0>,
include <tunables/global>
profile bwrap /usr/bin/bwrap flags=(unconfined) {
  userns,
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/bwrap

After applying, bwrap --bind / / /bin/echo ok runs 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:

  1. Document the AppArmor profile prerequisite in the Linux install instructions
  2. Detect the AppArmor restriction at startup and surface an actionable error pointing at this thread

Happy to PR docs if useful.

midiisc · 1 month ago

Another bwrap failure mode in this bucket: per-path bind args overflow ARG_MAX when launched from a large-tree cwd → every sandboxed Bash call dies with E2BIG

Confirming 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 bwrap argv grows with the size of the cwd tree. Past a point it exceeds the kernel's ARG_MAX, and posix_spawn('/bin/bash') fails before the shell ever runs:

E2BIG: argument list too long, posix_spawn '/bin/bash'

Minimal A/B — identical command, identical settings (sandbox.enabled: true), only the launch directory differs:

# tiny cwd (1 file)
$ cd /data/scratch && claude -p 'run: echo OK' --dangerously-skip-permissions
→ OK                       # sandboxed echo runs fine

# large cwd (506,606 files, $HOME)
$ cd ~ && claude -p 'run: echo OK' --dangerously-skip-permissions
→ E2BIG: argument list too long, posix_spawn '/bin/bash'

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):

  • Bind the writable root as a single subtree instead of enumerating per-path binds.
  • Pass binds via a --args <fd> file/descriptor to sidestep the argv/ARG_MAX ceiling.
  • Bound the number of generated binds and surface a clear diagnostic instead of a raw E2BIG.

Workarounds today: launch claude from 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.)

midiisc · 23 days ago

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's permissions.deny glob-expansion hitting the per-argument limit.

Root cause (from the bundled sandbox builder nFi + live measurement)

  1. The sandbox merges permissions.deny Read(...) rules into the bwrap filesystem deny set and glob-expands each rule into one --ro-bind /dev/null <path> per matching file on disk.
  2. It then serializes the entire bwrap invocationbwrap + every bind + -- /bin/bash -c <usercmd> — into a single shell-quoted string, and runs that string as bash -c "<P>".
  3. So the binding limit is MAX_ARG_STRLEN (128 KiB, the per-single-argument cap), not the 2 MiB total ARG_MAX. One over-broad glob is enough.

A common, innocent rule triggers it:

// ~/.claude/settings.json
"permissions": { "deny": [ "Read(**/*.pem)" ] }

**/*.pem expanded 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 Cargo openssl/jsonwebtoken test fixtures, plus uv/pipx venv certs. That serialized to ~141 KB as a single bash -c argument → posix_spawn('/bin/bash')E2BIG, regardless of cwd.

Minimal measurement (reproduces the kernel error directly)

# 887 `--ro-bind /dev/null <pem>` triples serialized as ONE bash -c arg:
#   len(P) = 133,825 bytes  (> MAX_ARG_STRLEN 131,072)
subprocess.run(["bash","-c", P])
#   OSError: [Errno 7] Argument list too long: 'bash'   <-- E2BIG, before bash runs
# dir-scoped equivalent: len(P) = 609 bytes -> bwrap runs fine, secret dirs still tmpfs-masked

Suggested fixes (any one resolves it; defense-in-depth = all three)

  1. Pass bwrap args via --args <fd> (NUL-separated, read from a pipe) instead of one giant bash -c string — removes the single-arg ceiling entirely. This is the real fix.
  2. Don't glob-expand a deny rule into one bind per file. A pattern like **/*.pem should be enforced as a subtree/regex rule, not materialized into hundreds of concrete --ro-bind args.
  3. With allowUnsandboxedCommands: true, catch E2BIG and 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), …) in permissions.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 sandbox denyRead (which masks the dir from in-sandbox Bash too). Replacing the blanket **/*.pem with 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.

AgainPsychoX · 22 days ago

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.

SendiMyrkr · 22 days ago

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 ext4 workspace, the cwd-resolved deny-list (the Rx4/oiH/kx4 bug pinned by @poltimmer above) produces --ro-bind /dev/null <project>/.bashrc etc. that succeed — bwrap mknods a char-device/0-byte placeholder. Cosmetic pollution, shell still runs.

On virtiofs-over-NTFS, that target-creation/mknod isn't supported, so the same bind fails outright:

bwrap: Can't bind mount /oldroot/dev/null on /newroot/<project>/.claude/launch.json:
  No such file or directory

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 .gitignore workarounds 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/workflows appears once a skill runs, .claude/routines/.claude/scheduled_tasks.json once scheduled tasks/loops run — so each failure names a different file.

Full deny-target set observed (workspace-relative, via a bwrap argv-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. .gitignore alone is insufficient here.

This strengthens the case for the suggested fixes beyond the one-line cwd → homedir correction: 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-bind per path would also sidestep this class entirely.

Still reproduces; the underlying deny-list code path is unchanged.