Sandbox filesystem protection leaves stray placeholder files / bind mounts in project working tree

Status Open
Maintainer reply None cached
Activity 4 comments · opened Jul 27, 2026

Summary

Claude Code's sandbox filesystem protection (which appears to bind-mount
placeholder files over paths it wants to protect from writes, e.g. its own
config directory) is leaving visible artifacts inside a project's git
working tree, in two related manifestations.

Manifestation 1 (lighter, previously observed)

Occasionally, a Bash tool call fails with an error like:

bwrap: Can't find source path /path/to/project/.claude/hooks: No such file or directory

even for commands unrelated to that path (e.g. gh pr view). This appears
transient — retrying the exact same command in a new Bash tool call
typically succeeds.

Manifestation 2 (heavier, newly observed)

During a longer session, git status --untracked-files=all in the project
root started showing a large number of untracked files that were never
created by any of my actions:

  • Dotfiles normally located under the user's home directory (e.g. .bashrc,

.gitconfig, .idea, .vscode) appearing instead at the project root

  • Files matching the project's own .claude/ subdirectory names (e.g.

.claude/hooks, .claude/commands, .claude/agents, .claude/workflows,
.claude/launch.json, .claude/loop.md, .claude/output-styles,
.claude/routines, .claude/.mcp.json, and a project-root .mcp.json)

  • A duplicated nested directory, i.e. .claude/.claude/<same names again>

Inspecting /proc/self/mountinfo from within a Bash tool call confirmed
these are real, active mounts, e.g.:

<id> <parent> 0:5 /null /path/to/project/.gitconfig ro,nosuid,nodev,relatime - devtmpfs none ...
<id> <parent> 8:48 /path/to/project/.claude/hooks /path/to/project/.claude/hooks ro,nosuid,nodev,relatime - ext4 ...

i.e. /dev/null (from devtmpfs) or the path itself bind-mounted read-only
over itself.

Attempting to rm these from within a Bash tool call failed with
"Device or resource busy" for all but the duplicated .claude/.claude/
directory (which was a plain directory, not a live mount, and deleted
successfully).

Cross-checking outside the sandbox

Running the same git status from a plain terminal (outside Claude Code's
sandbox) on the same checkout showed a subset of the same files:

  • The home-directory dotfiles (.bashrc, .gitconfig, .idea, .vscode,

etc.) at the project root did not appear — these seem to exist only
within the sandbox's own mount namespace and not on the real filesystem

  • The .claude/-prefixed files (.claude/hooks, .claude/commands,

.claude/agents, .claude/launch.json, .claude/loop.md,
.claude/output-styles, .claude/routines, .claude/workflows,
.claude/.mcp.json) and the project-root .mcp.json did appear, and
were deletable with a plain rm from that outside-sandbox terminal —
meaning these were real files/mounts on the actual filesystem, not just
visible inside the sandbox

Hypothesis

The sandbox's write-protection list for Claude Code's own config paths
(things like <project>/.claude/hooks, <project>/.claude/commands, etc.)
seems to get applied using a relative-looking path (.claude/hooks) that
matches both the intended location (under the user's home directory) and,
coincidentally, the project's own .claude/ directory (used for
project-local Claude Code config/worktrees). When the intended target
doesn't already exist as a real file, the protection mechanism appears to
materialize a placeholder (empty file or /dev/null bind mount) at
whichever path actually matched — including, in this case, inside the
project repository itself.

Environment

  • WSL2 (Ubuntu) on Windows
  • Claude Code CLI, sandboxed/auto-allow mode enabled in project settings

Impact

No data loss observed — the artifacts were all empty/zero-byte or
/dev/null-backed. However, having real files silently appear inside a
git working tree (which git status then reports as untracked changes)
is confusing and could plausibly be accidentally committed if a
non-fastidious git add -A were run.

Ask

Could the maintainers confirm whether this bind-mount/placeholder
mechanism is intended to ever touch paths inside the current project
directory (as opposed to only the user's home directory), and if not,
whether the path-matching logic could be anchored to an absolute path to
avoid this collision?

View original on GitHub ↗

3 Comments

sorakubo · 1 month ago

Additional finding: same mechanism also affects .git/ internals

While cleaning up old git worktrees in the same project, I found that the
"could not lock config file .git/config: File exists" error (and failures
to remove old worktree directories with "Device or resource busy") share
the same root cause as the issue above.

Inspecting /proc/self/mountinfo showed that .git/worktrees/<name>/config.worktree
and .git/worktrees/<name>/commondir are each bind-mounted read-only onto
themselves, e.g.:

<id> <parent> 8:48 /path/to/project/.git/worktrees/<name>/config.worktree /path/to/project/.git/worktrees/<name>/config.worktree ro,nosuid,nodev,relatime - ext4 ...

This is the exact same self-mount signature as .claude/hooks in the
original report. It looks like the write-protection meant for .git/config
(presumably to stop Claude from tampering with git remote/credential
config) is matching on a substring/basename pattern that also catches
config.worktree files nested under .git/worktrees/*/, and possibly
commondir too.

Concretely, this means:

  • git push -u / git branch --set-upstream-to fail with

"could not lock config file .git/config: File exists" (the push/branch
operation itself succeeds; only the config write for tracking fails)

  • git worktree remove and git worktree prune fail to delete the

.git/worktrees/<name>/ administrative directory for old, already-gone
worktrees, leaving git worktree prune -v reporting dozens of stale
"Device or resource busy" entries that accumulate over time (all cosmetic
— the actual worktree directories are already gone — but never cleaned up)

This suggests the underlying protection list matching is not scoped
narrowly enough to .git/config itself, and is instead catching any path
whose basename/suffix looks like a git config file, across the whole .git/
tree (and, per the original report, across the project tree more broadly
too).

sorakubo · 1 month ago

Additional data point on this issue: encountered the same signature on a file path that doesn't match any of the previously-suspected protected-path names.

After a plain git pull on main (no worktree operations involved this time), .gitmodules in the repository root appeared as a character device:

crw-rw-rw- 1 nobody nogroup 1, 3 <date> .gitmodules

Same major/minor (1,3 = /dev/null) signature as previously reported for .claude/hooks, .claude/skills, .git/config, .git/worktrees/*/config.worktree, etc.

Notably, .gitmodules isn't tracked by this repository at all (git ls-files .gitmodules returns nothing, git status shows it as untracked) and doesn't match any of the filenames I'd expect the sandbox's protective bind-mount list to target (no hooks, config, settings, etc. in the name). This suggests the path-matching logic behind the protective mount may be broader or less precise than an exact protected-path list — possibly a mismatch in path resolution rather than a filename allowlist. No data loss observed; the file being untracked meant no functional impact beyond noise in git status.

arbeitandy · 10 days ago

Root cause, measured on a native Linux install (not WSL2)

Both manifestations come from one mechanism. I traced it on Claude Code 2.1.238, Debian 13, bubblewrap 0.11.0.

1. The sandbox picks a bwrap argument by an existence check

From strings on the 2.1.238 native binary:

if (D(ue)) u.push("--ro-bind", ue, ue)           // exists  -> bind the path read-only onto itself
else       u.push("--ro-bind", "/dev/null", Re)  // missing -> "Mounted /dev/null at ${Re} to block creation of ${ue}"

D is an existence check. The two branches fail in opposite ways:

$ bwrap --ro-bind / / --ro-bind $T/sub/nope $T/sub/nope true
bwrap: Can't find source path /tmp/bwrap-probe.oy2c/host/sub/nope: No such file or directory

$ bwrap --bind / / --ro-bind /dev/null $T/sub/created-by-bwrap true
$ ls -la $T/sub/
-r--r--r-- 1 user user 0 Aug 21 04:47 created-by-bwrap

The second command is Manifestation 2. / is bind-mounted from the host, so bwrap materialises the missing
destination as a real 0-byte, mode 444 file in the working tree.

2. Cleanup counts one process; the filesystem is shared

function Lap(){ if(Rap) return; process.on("exit", ()=>{ Ooi({force:true}) }); Rap = true }

function Ooi(e){
  if(!e?.force){
    if(MMt>0) MMt--;
    if(MMt>0){ log(`[Sandbox Linux] Deferring mount point cleanup — ${MMt} sandbox(es) still active`); return }
  }
  for (let t of $oi) { let r = statSync(t); if (r.isFile() && r.size === 0) unlinkSync(t) }  // "Cleaned up bwrap mount point (file)"
}

MMt counts the active sandboxes of one process. $oi holds the placeholders that same process fabricated.
Neither sees the other claude processes on the machine. With two sessions rooted under one tree:

  1. Process A finds <ancestor>/.claude/settings.json missing and mounts /dev/null over it. bwrap creates the 0-byte file on the host.
  2. Process B builds its arguments. The existence check passes, so B emits --ro-bind <path> <path>.
  3. A's command ends, A's counter reaches 0, and A unlinks the file.
  4. B's bwrap aborts with Can't find source path <path>. Exit 1, and the command never runs.

A retry succeeds because the check runs again, now returns false, and takes the /dev/null branch.

Field data

One session lost three commands in 15 minutes, each on a different ancestor:

| time | missing source | path present again |
|---|---|---|
| 04:30:38 | <repo>/.git/worktrees | — |
| 04:43:15 | <parent>/.claude/settings.json | 04:44 |
| 04:45:10 | <grandparent>/.git | 04:46 |

Three claude processes were running, all rooted under the same directory. Each failing path appeared on disk
a minute later, when a later invocation took the /dev/null branch and bwrap recreated it.

On the path matching

The deny set applies at every ancestor of the working directory, not only inside the project. On this box the
artifacts sit at the project root, at its parent and at its grandparent: ~/src/.claude/{settings.json,agents,
commands,hooks,launch.json,loop.md,output-styles,routines,scheduled_tasks.json,workflows}
, ~/src/.mcp.json,
~/src/.git/{config,config.lock}, and ~/src/<org>/{.bashrc,.zshrc,.profile,.gitconfig,.gitmodules,.idea,.vscode}
— 60 files in total. #83129 reports the same ancestor walk climbing past $HOME into /home.

Related reports

  • #83099 describes the same placeholder materialisation with a different symptom: a session that starts inside

the window reads the 0-byte file and reports invalid JSON.

  • #79248 is the same argument-build race with a git lockfile as the source.
  • #78818 carries a maintainer confirmation of the same materialisation on 2.1.233, and dates it to a hardening

change around 2.1.193. My box runs 2.1.238 and still shows it.

How this relates to the fix in review

#78818 says a fix "that stops creating the lock file on Linux" is in review. That fix targets one filename.
The placeholder mechanism stays for every other protected path, and the race above needs no lock file at all —
the three failures I measured were on .git/worktrees, .claude/settings.json and a .git directory.

One detail matters for the fix design. #78818 reads the clean exit as the safe path: "On a clean exit the file
is cleaned up." The cleanup is what breaks a concurrent session. Process A's tidy exit unlinks a placeholder
that process B has already put in its argument list, and B's next bwrap call dies. Cleaning up sooner, or more
often, makes this failure more frequent rather than less.

Workaround

Cleanup only unlinks files of zero length. Writing one byte into the path that keeps failing — {} for a
settings file — holds the existence check true and stops the branch from flipping. I checked that a stub
.git/config does not make git treat the directory as a repository, so that mitigation is safe for git.

What I did not verify

I never caught the unlink in flight. The chain rests on the two code fragments and on the timeline above.
The [Sandbox Linux] messages quoted here exist in the binary, but a run with --debug-file did not contain
them. If a category flag turns that logger on, please say which — it would let people confirm this in the field.

Ask

Would you create the mount points inside the private namespace rather than on the host filesystem? That removes
the stray files and the race together. If the host path has to stay, please re-check existence at spawn time and
fall back to the /dev/null branch when the path has gone.

Environment: Claude Code 2.1.238 (native install), Debian 13 trixie, kernel 6.12.88, bubblewrap 0.11.0,
sandbox enabled, filesystem.allowWrite: ["~", "/tmp"].

Showing cached comments. Read the full discussion on GitHub ↗