[BUG] Linux sandbox: bwrap mount-point cleanup is per-process with no cross-process refcount — concurrent sessions in one project root kill each other's Bash calls
Preflight
- Searched existing issues; the closest is #81602, which reports a symptom of this without the mechanism. Cross-links in the next section.
- Single bug: the mount-point lifecycle has no cross-process refcount.
- Reproduced continuously on 2.1.245 through 2.1.270; mechanism below read out of the 2.1.270 binary.
Related issues (none is this bug, all worth cross-linking)
- #81602 — "Sandbox filesystem protection leaves stray placeholder files / bind mounts in project working tree". Closest neighbour. Its Manifestation 1 (
Can't find source path .../.claude/hooks, transient, retry succeeds) is almost certainly this bug observed from outside; the report has no mechanism and frames the whole thing as leaked artifacts. Its Manifestation 2 (placeholders surviving) is the opposite failure of the same lifecycle. - #77271 —
Can't create file at <cwd>/.claude/settings.json: Read-only file systemon fresh project dirs. That is message shape (4) below; it is a distinct trigger (parent already read-only in the same session) and is already filed, so shape 4 is mentioned here only for completeness. - #79248 —
Can't get type of source .../config.lockfrom a shared.git. Identical symptom string, different source: there the vanishing file is git's own transient lockfile, not a placeholder Claude Code minted. We see both in our logs. Do not let this one get closed as a dup of that one. - #46165, #78072 — the "stray 0-byte placeholder files" complaints; same placeholders, cleanup-didn't-run direction.
- #89514 — intermittent multi-shape bwrap failure on WSL2 + Docker. Environment-specific, and its shapes are
Permission denied/capset, notNo such file or directory. Probably unrelated.
Summary
On Linux, for each sandbox-denied path that sits inside an allowed write root and does not exist on disk, Claude Code has bubblewrap materialise a placeholder at that path (--ro-bind /dev/null <path>, or --ro-bind <tmpdir> <path> for a directory) so that nothing inside the sandbox can create a real file there. bwrap creat()s the destination on the real filesystem — the parent is a read-write bind of the project root — and Claude Code records the path in a module-level set so it can unlink it later.
The bookkeeping around that set is per-process. The in-flight counter is a plain module variable, decremented after each sandboxed command; when it reaches zero the process unlinks every placeholder it knows about. There is no lock, no registry, and no refcount shared with any other claude process.
So whenever two or more claude processes use the same project root — two interactive sessions, an interactive session plus a cron/headless run, a session plus a claude -p worker — process A's cleanup deletes the very mount points process B just put in its bwrap argv, and B dies at exec. The failure is user-visible as a Bash tool call that fails for no reason and succeeds on retry.
This is not exotic; any repo where someone runs a second claude in a second terminal is exposed. In our environment (a home directory with a cron-driven headless runner plus interactive sessions plus subagent lanes, all in a handful of project roots) it produced 84 distinct sessions with at least one such failure between 2026-07-07 and 2026-09-10, and that count is a floor — see the census note at the bottom.
Mechanism, from the 2.1.270 bundle
Two functions, module-scoped, deobfuscated only by renaming:
// argv builder — runs once per sandboxed Bash invocation
inFlight++; // `jj++`
// ...
if (!fs.existsSync(denyPath)) {
// ... destination does not exist: have bwrap create it
if (isDir) {
const empty = fs.mkdtempSync(path.join(tmp(), "claude-empty-"));
argv.push("--ro-bind", empty, mountPoint);
} else {
argv.push("--ro-bind", "/dev/null", mountPoint);
}
createdMountPoints.add(mountPoint); // `Oxe.add(...)`
registerExitCleanup(); // `RVt()`
log(`[Sandbox Linux] Mounted /dev/null at ${mountPoint} to block creation of ${denyPath}`);
} else if (withinAllowedWrite(denyPath)) {
argv.push("--ro-bind", denyPath, denyPath); // self-bind an existing deny path
}
var createdMountPoints = new Set(), inFlight = 0, exitHookInstalled = false;
function registerExitCleanup() { // `RVt`
if (exitHookInstalled) return;
process.on("exit", () => { cleanupMountPoints({force: true}) });
exitHookInstalled = true;
}
function cleanupMountPoints(opts) { // `Dxe`
if (!opts?.force) {
if (inFlight > 0) inFlight--;
if (inFlight > 0) {
log(`[Sandbox Linux] Deferring mount point cleanup — ${inFlight} sandbox(es) still active`);
return;
}
} else inFlight = 0;
for (let p of createdMountPoints) try {
let st = fs.statSync(p);
if (st.isFile() && st.size === 0) { fs.unlinkSync(p); log(`[Sandbox Linux] Cleaned up bwrap mount point (file): ${p}`); }
else if (st.isDirectory() && fs.readdirSync(p).length === 0) { fs.rmdirSync(p); log(`[Sandbox Linux] Cleaned up bwrap mount point (dir): ${p}`); }
} catch {}
createdMountPoints.clear();
}
cleanupMountPoints() is called after essentially every sandboxed Bash invocation, plus on process.on("exit") and on sandbox teardown. inFlight is the only guard, and it is per-process module state — which is exactly the assumption that breaks with a second claude.
Note the guard is also doing double duty: because createdMountPoints is .clear()ed on every run, a placeholder minted by a sibling in-flight Bash call in the same process can be forgotten (leaked) or, with the counter at zero, unlinked while that sibling's argv is still being built. The single-process case is therefore racy too, just much rarer than the cross-process one.
Four error shapes, one race at four different instants
Grepping for any single one of these undercounts the bug badly. All four are the same lifecycle:
bwrap: Can't get type of source <path>: No such file or directory— the common case. The deny path existed when argv was built, so Claude Code emitted a self-bind--ro-bind <path> <path>; another process unlinked it beforeexec.bwrap: Can't bind mount /oldroot/dev/null on <path>: Unable to remount destination with correct flags: No such file or directory— one step later in the same race: the destination survived bwrap'screat()check and vanished before the remount.bwrap: Can't find source path <path>: No such file or directory— shape (1) for a directory source..claudeis on the denied-directory list as a whole, not merely through its children, so a cwd with unanchored ancestors (e.g. a scratch dir under/tmp) hits this on the ancestor directory itself.bwrap: Can't create file at <path>: Read-only file system— the mount point cannot becreat()ed because its parent is already bound read-only by this same session. Distinct trigger, already filed as #77271; listed here so the family is complete.
bwrap reports only the first bad entry in argv order, which is why the overwhelming majority of reports name .claude/hooks or .claude/settings.json no matter which path actually raced. That has misled at least two of the neighbouring issues into environment-specific hypotheses.
Real examples from our logs, verbatim:
bwrap: Can't find source path /home/<user>/repos/app/<project>/.claude/settings.json: No such file or directory
bwrap: Can't get type of source /home/<user>/notes/claude/.claude/hooks: No such file or directory
bwrap: Can't get type of source /home/<user>/repos/app/<project>/.worktrees/.claude/launch.json: No such file or directory
bwrap: Can't get type of source /home/<user>/notes/emberlight/cost breakdown/.mcp.json: No such file or directory
bwrap: Can't find source path /tmp/claude-1000/.claude: No such file or directory
Reproduction
mkdir /tmp/bwrap-race && cd /tmp/bwrap-race && git init— a project root with no.claude/directory and no.mcp.json, so the deny paths are all non-existent and therefore all get placeholders.- In two terminals, in that directory, start two sandboxed sessions and have each issue a long run of quick Bash calls (
for i in $(seq 100); do ls >/dev/null; doneas separate tool calls, or simply twoclaude -pworkers doing file-heavy work). - Within a minute or two, one of them fails a Bash call with one of the four messages above. Retrying the identical command usually succeeds, because the next argv build re-mints the placeholder.
Run either session with --debug and the interleaving is explicit in the log: Mounted /dev/null at … to block creation of … from one process, Cleaned up bwrap mount point (file): … from the other, on the same path.
The probability scales with how often each process runs Bash and how many deny paths are missing in that root, which is why a bare project directory (nothing exists, so everything is a placeholder) races much harder than a fully populated one.
Why --ro-bind-try is not the fix
The tempting one-line patch is to change the binds to --ro-bind-try. It should be avoided, for different reasons in each half of the code path:
- For the self-bind case (shape 1), source and destination are the same deny path.
--ro-bind-trymakes bwrap silently skip the bind when the source is missing — so the sandbox starts successfully with nothing mounted over the deny path, and anything inside the sandbox can then create a real file there. That converts a loud crash into a silent fail-open on exactly the paths the deny list exists to protect (.git,.claude/settings.json,.bashrc,.envrc, …). Strictly worse than the current behaviour. - For the placeholder case (shapes 2 and 4), the source is
/dev/null, which always exists.--ro-bind-tryguards only the source, so it changes nothing at all.
The fix has to make the mount point's lifetime outlive every process that might be binding it.
Suggested fixes
In rough order of how well they match the actual invariant:
- Cross-process refcount. Keep the placeholder registry in a small shared state file keyed by project root (e.g. under
~/.claude/or$XDG_RUNTIME_DIR), with an advisory lock, holding an entry for each live sandboxed exec. Unlink a placeholder only when the last holder across all processes releases it, and reap stale entries by pid liveness. This is the direct repair of the broken assumption and keeps the current "leave no artifacts behind" property. - Hold a lock across the argv-build → exec window. Narrower and much simpler: take a per-root advisory lock before building argv, release it after
exechas happened. It closes the window that produces shapes 1 and 2 without requiring a durable registry, at the cost of serialising sandbox launches within a root. - Stop unlinking mid-session. Clean up only on process exit, never on the per-command in-flight-hits-zero path. Does not fix the cross-process case by itself, but it collapses the churn from many times an hour to once per session and would make the race rare. Cheapest partial mitigation.
- Don't materialise mount points on the real filesystem. The root cause underneath the race is that a mount point for a non-existent path has to be created on the underlying filesystem, because the parent is a read-write bind of the real directory. If the deny-blocking for non-existent paths were expressed inside the sandbox's own mount namespace instead — so the placeholder never exists on disk for another process to see or delete — the race, the "stray files in my working tree" reports (#81602, #46165, #78072), and the commit-churn complaints all go away together.
Worth noting that (1)/(2) and (4) also settle the open tension between this issue and #81602: today, cleanup that is too eager breaks concurrent sessions, and cleanup that is too lazy leaves artifacts in people's git status. Both reports are the same lifecycle being tuned against itself.
Workaround, for anyone who lands here from a search
The cleanup only ever removes 0-byte files and empty directories, and a deny path that already exists on disk with real content never enters the churn set at all (it takes the self-bind branch and is never registered for cleanup). So: pre-create every deny path in the project root as a real, non-empty, reader-safe file, and commit it.
Contents matter, because several of these paths have live readers — a non-empty .envrc wakes direnv, .pre-commit-config.yaml gets executed, .npmrc/.yarnrc change package-manager behaviour, and .git must obviously never be faked. We anchor only the safe subset (.claude/ tree with {} for the JSON settings files and a .keep in each subdirectory, .mcp.json as {"mcpServers":{}}, the shell rc files as a single comment line, .gitconfig, .gitmodules, .ripgreprc, .vscode, .idea), regenerated idempotently by a cron job that never overwrites a non-empty file.
That removed the failure almost entirely in our environment: of the 84 affected sessions between July 7 and September 10, only three fall after the anchors landed on September 8, and those were in roots the script did not yet cover. It also ended a separate annoyance — an hourly automated commit that had been picking up the same eight zero-line .claude/ "changes" every time.
It is a workaround, not a fix: it only covers paths you are willing to materialise, it cannot help shape (4) at all (the parent is already read-only inside the sandbox, so nothing outside can anchor it), and it does nothing for scratch/worktree directories that are created on the fly.
Census note
If you want to measure this in your own logs, count tool results that are errors and contain bwrap, and match all four message shapes anchored at line start. Grepping for the phrase alone overcounts by roughly 15x, because every later session that reads a transcript or note quoting the error matches too.
Environment
- Claude Code 2.1.245 → 2.1.270 (mechanism above verified against 2.1.270; unchanged since at least 2.1.263)
- Linux 6.8, Ubuntu, bubblewrap from the distro
- Built-in Bash sandbox enabled, ordinary per-user install, no managed settings involved
- Multiple concurrent
claudeprocesses per project root: interactive sessions, headlessclaude -pworkers, and cron-launched runs
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗