[BUG] Built-in bwrap sandbox: launch races transient git lockfiles under a shared `.git` → bwrap exits 1 "Can't get type of source .../config.lock"
Preflight
- [x] I searched existing issues and did not find a duplicate.
- [x] This report is about a single bug.
- [x] The failure mode is reproduced by a standalone
bwrapharness on the same host/toolchain running Claude Code 2.1.215. (The Claude Code sandbox is closed-source; that it constructs the.gitbind list per-entry is inferred from the error — see Root Cause. The failure has not been driven end-to-end through the Claude Code sandbox itself.)
What's Wrong? (Actual)
When Claude Code launches a sandboxed child process (e.g. a sub-agent / parallel task whose sandbox shares a repository's .git) while another git process concurrently writes git config on that same .git (or a shared commondir), the sandbox launch intermittently fails before the sandboxed command runs at all:
bwrap: Can't get type of source /path/to/repo/.git/config.lock: No such file or directory
bwrap exits 1, so the sandboxed process never starts. The failure is transient: re-running the exact same command almost always succeeds, because the offending file is a short-lived git lockfile that has already been renamed/unlinked by the time you retry.
The observed trigger is config.lock. By the same mechanism, any git atomic-write lockfile that lives directly under .git (e.g. index.lock, packed-refs.lock) could plausibly trigger it — but only config.lock was actually reproduced. Note: per-ref lockfiles live under .git/refs/**/*.lock, i.e. not directly under .git, so they are not covered by the per-entry .git enumeration described below.
What Should Happen? (Expected)
A transient git lockfile that vanishes during sandbox setup should not abort the launch. The sandbox should either skip a bind source that disappeared between enumeration and mount, or snapshot the entry set atomically so a source seen during enumeration is guaranteed present at mount time. Sandbox launch should be deterministic regardless of concurrent, unrelated git activity on the shared .git.
Error Messages / Logs
bwrap: Can't get type of source /path/to/repo/.git/config.lock: No such file or directory
# process exit code: 1
Note on bwrap's two distinct messages (relevant to the fix): a path that never existed yields Can't find source path … (raised during early arg resolution), whereas a path that existed at enumeration but vanished before the type-stat yields Can't get type of source … (raised in the mount-setup get_file_mode(src) stat). This bug is the latter — a genuine time-of-check/time-of-use race, not a missing path passed up front.
Root Cause (mechanism)
Legend: [observed] = directly measured on our host with the standalone harness below; [inferred] = deduced from external behavior, not verifiable inside the closed-source Claude Code binary.
To make a shared .git writable inside the sandbox while keeping config/hooks read-only, the sandbox does not bind .git as one mount. Instead it appears to enumerate the entries directly under .git (readdir) and build a per-entry list of bind arguments (--ro-bind/--bind) that it passes to bwrap. [inferred] — deduced from (a) the bwrap error naming a .git child as a bind source, and (b) the observable "shared .git writable, but config/hooks read-only" behavior, which requires per-child binds rather than one directory bind. We could not inspect the binary's internals.
The race, step by step [observed on a standalone harness, see below]:
- The bind list is constructed from a
readdirof.gitat time T0. - A separate git process performs a config update as an atomic write:
create config.lock (O_CREAT|O_EXCL)→ write →rename(config.lock → config)orunlink(config.lock). The lockfile therefore lives for only a brief window. Typical producer:git config, or any concurrent git command that rewrites config on a sharedcommondir. - If such a writer holds
config.lockat T0, it is included in the bind list. bwraplater (T1 > T0)stat()s each bind source to determine its type. By T1 the writer has alreadyrename()d/unlink()edconfig.lock.bwrapcannot get the type of a now-missing source →Can't get type of source …/.git/config.lock→ exit 1. The sandboxed command never launches.
Corroborating fact [observed, strace]: in the scenario we traced, the git command running next to the sandbox (git worktree add) opened config O_RDONLY and did not itself create config.lock — the config.lock writer was a separate concurrent git process. This is consistent with the requirement that the writer is a distinct process; a repository with no concurrent config writer does not hit this.
Steps to Reproduce (standalone, Claude-Code-independent)
The following harness reproduces the identical bwrap failure using only bwrap + bash — no Claude Code involved. It emulates the two racing parties: a victim that does per-entry .git bind enumeration (the behavior we infer the sandbox uses), and separate concurrent writers doing git-style transient config.lock create→remove.
Prerequisites. bwrap (bubblewrap) + bash, with unprivileged user namespaces enabled. On hardened Ubuntu/Debian (kernel.apparmor_restrict_unprivileged_userns=1, default on recent Ubuntu) an unconfined bwrap is blocked and you'll get a different error (e.g. bwrap: setting up uid map / No permissions to create new namespace) — that is a setup failure, not this bug. Verify bwrap --ro-bind / / /bin/true succeeds first. The failure rate is timing/load/core-count dependent (measured on a 32-core host); on few-core or idle machines the window may not open — widen it with more WRITERS or higher RUNS, or you may see 0 failures.
#!/usr/bin/env bash
# repro-bwrap-bind-race.sh — reproduces the bwrap readdir->stat TOCTOU only.
# Requires bwrap (bubblewrap) + bash, with unprivileged user namespaces enabled
# (verify `bwrap --ro-bind / / /bin/true` first). Does NOT run Claude Code.
# Usage: ./repro-bwrap-bind-race.sh [RUNS] [WRITERS]
set -u
RUNS="${1:-300}"; WRITERS="${2:-16}"
WORK="$(mktemp -d)"; GITDIR="$WORK/repo/.git"
mkdir -p "$GITDIR/refs" "$GITDIR/objects" "$GITDIR/hooks"
for f in HEAD config description index packed-refs; do : > "$GITDIR/$f"; done
# Concurrent writers: emulate the BRIEF lifetime of a git atomic-write lockfile.
# Git itself does create(config.lock, O_CREAT|O_EXCL) -> write -> rename/unlink;
# here we only need a file that appears and disappears rapidly under .git, so we
# create+remove it in a loop (a generic transient file, NOT git's exact O_EXCL
# semantics — the multiple writers just widen the race window). These are the
# OTHER, separate writer processes — never the sandboxee. Real-world producers of
# such transient files: `git config` and any concurrent git command that rewrites
# config on a shared .git/commondir.
LOCK="$GITDIR/config.lock"; : > "$WORK/run"; pids=()
for _ in $(seq 1 "$WRITERS"); do
( while [ -e "$WORK/run" ]; do : > "$LOCK" 2>/dev/null; rm -f "$LOCK" 2>/dev/null; done ) &
pids+=($!)
done
# Victim: per-entry bind enumeration, the behaviour we INFER the sandbox uses.
# readdir(.git) builds one --ro-bind per child, THEN bwrap stat()s each source;
# config.lock can be present at readdir and gone by bwrap's stat() -> failure.
# NOTE: the signature string below matches bwrap 0.9.0; other versions may word
# the error differently, so adjust `sig` if you run a different bwrap.
sig='bwrap:.*can.?t get type of source .*/\.git/[^ ]*\.lock: no such file or directory'
fail=0
for i in $(seq 1 "$RUNS"); do
binds=()
for entry in "$GITDIR"/*; do # readdir snapshot (no per-entry stat)
binds+=( --ro-bind "$entry" "/mnt/.git/${entry##*/}" )
done
err="$(bwrap --ro-bind / / --tmpfs /mnt --dir /mnt/.git "${binds[@]}" /bin/true 2>&1)"
rc=$?
if [ "$rc" -ne 0 ] && printf '%s' "$err" | grep -Eiq "$sig"; then
fail=$((fail+1)); [ "$fail" -le 3 ] && printf 'sample failure (run %d): %s\n' "$i" "$err" >&2
fi
done
rm -f "$WORK/run"; wait "${pids[@]}" 2>/dev/null; rm -rf "$WORK"
awk "BEGIN{printf \"bind-race launch failures: %d / %d (%.1f%%)\n\", $fail, $RUNS, 100*$fail/$RUNS}"
Observed output [observed] (bwrap 0.9.0, kernel 6.8.0-136-generic, x86_64, 32-core host):
sample failure (run 18): bwrap: Can't get type of source /tmp/tmp.XXXX/repo/.git/config.lock: No such file or directory
...
bind-race launch failures: 19 / 300 (6.3%)
The reproduction rate is timing/load/core-count dependent — across runs at 16 writers we saw roughly 6–8% (e.g. 19/300, 23/300). Raise WRITERS, or run under load, to widen the window; run on a few-core or idle machine and you may see 0.
Reproducing through Claude Code itself (caveat)
The harness reproduces the bwrap-level TOCTOU deterministically enough to measure, but it does not drive Claude Code's own sandbox. Reproducing end-to-end through Claude Code requires: (a) the built-in bwrap sandbox to be the active launch path, (b) a sandboxed child that shares a repository's .git, and (c) a separate concurrent git process writing config on that .git/commondir. The exact bind list Claude Code passes to bwrap is not externally observable (closed-source binary), so "the sandbox enumerates .git per-entry" is inferred from the bwrap error naming a .git child as a bind source, not directly confirmed. An engineer with source access can confirm the enumeration and validate the fix directly.
Suggested Fix
Two options, in increasing robustness:
Option 1 — tolerate a vanished bind source (minimal, low-risk).
When building the per-entry .git bind list, use bwrap's --ro-bind-try / --bind-try for the .git children instead of the hard --ro-bind / --bind. The _try variants silently skip a source that is missing at mount time. Transient lockfiles are never needed inside the sandbox, so skipping them is safe.
Verified [observed]: swapping only the .git-child binds to --ro-bind-try in the harness above eliminates the failure entirely — 0 / 300 (0.0%) under the same 16-writer race that produced 6–8% with --ro-bind.
Option 2 — atomic-snapshot enumeration (more robust, larger change).
Make the enumeration and the mount decision atomic with respect to the source's existence: e.g. re-stat each entry immediately before emitting its bind arg and drop entries that no longer exist, or hold the entries open (openat + bind via /proc/self/fd/N) so the mount targets a stable inode rather than a path that can be unlinked between readdir and stat.
Option 1 is the smallest correct change and is directly validated above; Option 2 additionally protects against sources replaced (not just removed) mid-setup.
Impact & Frequency
- Impact: spurious, non-deterministic failure to launch a sandboxed process. Because the trigger (
config.lock) is transient, the same command succeeds on retry, so it manifests as flaky/intermittent sandbox launch failures rather than a persistent one. It does not corrupt anything, but it aborts otherwise-valid work and requires re-running. - Scope: only occurs when a separate git process concurrently writes config on the shared
.git/commondir(e.g. parallel git operations, multiple agents sharing one repo). Single-writer repos are unaffected. - Frequency: not directly measured for the real Claude Code sandbox. The standalone harness reproduces the identical
bwrapfailure at ~6–8% at 16 concurrent writers on a 32-core host (e.g. 19/300, 23/300); a tighter variant of the same emulation reached ~12% (37/300). The rate is strongly timing-, load-, and core-count dependent — on few-core or idle machines the window may not open and you may see 0 failures. In ordinary use (occasional concurrent git activity) the real-world rate is lower but non-zero.
Environment
| Field | Value |
|---|---|
| Claude Code Version | 2.1.215 (claude --version) |
| Platform | Unknown / not relevant — sandbox-launch infrastructure, provider-independent |
| Operating System | Ubuntu |
| Kernel / Arch | 6.8.0-136-generic, x86_64 |
| bubblewrap | 0.9.0 (/usr/bin/bwrap) |
| Terminal / Shell | bash (terminal-independent — the failure is at sandbox launch, not in any TTY) |
| Claude Model | N/A — infrastructure/sandbox-launch bug, not model-dependent |
| Is this a regression? | Not sure (no data on which versions first introduced per-entry .git binds) |
| Last working version | Unknown |
Additional Information
- Observed vs inferred, summary. [observed]: the exact error string + exit code; the standalone harness reproducing the identical error at ~6–8% (16 writers) on a 32-core host; that
--ro-bind-tryeliminates it (0/300); that in the traced scenario the neighbouring git command (git worktree add) openedconfigO_RDONLY(strace) and theconfig.lockwriter was a separate process. [inferred, not verifiable in the closed-source binary]: that the sandbox specifically constructs the.gitbind list per-entry with--ro-bind/--bind— deduced from the bwrap error naming a.gitchild as a bind source plus the "shared.gitwritable, config/hooks read-only" behavior that requires per-child binds; and therefore the end-to-end failure rate through Claude Code itself is not directly measured. - Nested sandboxing is unrelated. For completeness: bwrap-in-bwrap fails separately with
setting up uid map: Read-only file system; single-layer bwrap is fine. That is a different limitation and not what this report is about.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗