Sandbox filesystem protection leaves stray placeholder files / bind mounts in project working tree
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?
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Additional finding: same mechanism also affects
.git/internalsWhile 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/mountinfoshowed that.git/worktrees/<name>/config.worktreeand
.git/worktrees/<name>/commondirare each bind-mounted read-only ontothemselves, 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/hooksin theoriginal 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.worktreefiles nested under.git/worktrees/*/, and possiblycommondirtoo.Concretely, this means:
git push -u/git branch --set-upstream-tofail 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 removeandgit worktree prunefail to delete the.git/worktrees/<name>/administrative directory for old, already-goneworktrees, leaving
git worktree prune -vreporting 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/configitself, and is instead catching any pathwhose 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).
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 pullonmain(no worktree operations involved this time),.gitmodulesin the repository root appeared as a character device:Same major/minor (1,3 =
/dev/null) signature as previously reported for.claude/hooks,.claude/skills,.git/config,.git/worktrees/*/config.worktree, etc.Notably,
.gitmodulesisn't tracked by this repository at all (git ls-files .gitmodulesreturns nothing,git statusshows it as untracked) and doesn't match any of the filenames I'd expect the sandbox's protective bind-mount list to target (nohooks,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 ingit status.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
stringson the 2.1.238 native binary:Dis an existence check. The two branches fail in opposite ways:The second command is Manifestation 2.
/is bind-mounted from the host, so bwrap materialises the missingdestination as a real 0-byte, mode 444 file in the working tree.
2. Cleanup counts one process; the filesystem is shared
MMtcounts the active sandboxes of one process.$oiholds the placeholders that same process fabricated.Neither sees the other
claudeprocesses on the machine. With two sessions rooted under one tree:<ancestor>/.claude/settings.jsonmissing and mounts/dev/nullover it. bwrap creates the 0-byte file on the host.--ro-bind <path> <path>.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/nullbranch.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
claudeprocesses were running, all rooted under the same directory. Each failing path appeared on diska minute later, when a later invocation took the
/dev/nullbranch 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
$HOMEinto/home.Related reports
the window reads the 0-byte file and reports invalid JSON.
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.jsonand a.gitdirectory.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 asettings file — holds the existence check true and stops the branch from flipping. I checked that a stub
.git/configdoes 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-filedid not containthem. 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/nullbranch 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"].