Sandbox materializes & persists .git/config.lock in linked worktrees, blocking git config writes
Summary
In a linked git worktree, the Claude Code command sandbox read-only-protects the shared.git/config by bind-mounting the git-config file family read-only onto itself. It includes
the transient .git/config.lock in that protected set. Because a bind-mount requires its
target to exist, the sandbox creates .git/config.lock (an empty file) as the mount target
— and that file persists on disk after the sandbox exits.
A permanent empty .git/config.lock is exactly what git treats as "a config write is already
in progress", so every subsequent config write fails:
error: could not lock config file /path/.git/config: File exists
error: unable to write upstream branch configuration
…in every shell and every worktree sharing that .git, until the file is manually removed. It
is re-created on each sandboxed entry, so rm-ing it is whack-a-mole. It looks like git-repo
corruption; it is not.
Impact
Any config-writing git operation in a shared-worktree repo fails intermittently:
git switch -c <branch> origin/master(writes branch tracking)git push -u …(writes upstream)git branch -D …(removes branch config)git config …
Evidence (/proc/self/mountinfo, inside the sandbox):
.../.git/config .../.git/config ro,nosuid,nodev ext4
.../.git/config.lock .../.git/config.lock ro,nosuid,nodev ext4 ← transient lock, forced to exist
.../.git/config.worktree .../.git/config.worktree ro,nosuid,nodev ext4
ls .git/config.lock shows a 0-byte read-only regular file (or a /dev/null character-device
stand-in when the file was absent at sandbox startup). It remains on disk after the sandbox exits.
Reproduction
git worktree add ../wt2 -b tmp— a linked worktree sharing the main.git.- Run any sandboxed command inside it (materializes
.git/config.lock). - In any shell, run a config-writing git op there, e.g.
git switch -c foo origin/master. - →
error: could not lock config file .../.git/config: File exists.
Expected
The read-only protection should cover the persistent config files (config, config.worktree)
but exclude transient lock files (config.lock, and any *.lock). Protecting config
already prevents the agent from writing it — the final rename(config.lock → config) lands on
the read-only config mount and fails — so excluding config.lock from the protected set
removes no real protection while eliminating the persistent-lock bug.
Suggested fix
Exclude *.lock (at minimum config.lock) from the read-only bind-mount set for the shared.git, so the sandbox never has to materialize a persistent lock file. Alternatively, when a
denied path does not exist, deny cleanly (EPERM/EACCES) rather than creating and bind-mounting a
placeholder that outlives the sandbox.
Environment: Claude Code on WSL2 (Linux). Multiple linked git worktrees sharing one main .git.
4 Comments
I have the same workflow, WSL2 + multiple worktrees, and running into the same issue where claude will then refuse to go back to sandbox mode because of config.lock issues
This is not limited to linked worktrees — I hit exactly this in an ordinary, non-worktree repository, and the mechanism you describe is the right one.
Environment: Claude Code 2.1.232, Ubuntu on WSL2 (kernel
6.18.33.2-microsoft-standard-WSL2), bubblewrap sandbox enabled via managed settings. The repo is on a dedicated ext4 VHD attached to the WSL2 VM, not a Windowsdrvfsmount, so these are ordinary Linux filesystem semantics.The repository has
.git/as a real directory (not agitdir:pointer file), and.git/worktreesdoes not exist — no linked worktree has ever been created from it. It still gets both files:It broke an ordinary push run by hand in a normal shell, with Claude Code not running:
The push succeeded; only the local config write failed.
On why it isn't worktree-specific:
config.lockis added to the scrub list conditionally on.git/configexisting as a regular file, which is true of every normal repo —Two details that may help narrow the fix:
.git/config(771 bytes) and.git/info/exclude(541 bytes) were both left intact and unmodified, and.git/config.worktree.lock,.git/commondir,.git/modules,.git/worktreeswere never created. So nothing is clobbered — the defect really is just the missing unlink on teardown.Your second proposed fix — when a denied path doesn't exist, deny cleanly rather than creating a persistent placeholder — looks like the right general shape to me, and would cover the repo-root dotfile placeholders in #83497 and #81602 at the same time.
Worth adding for anyone else hunting this: verify with
ls, notgit status. Several of the placeholder names are commonly gitignored, which hides them completely. That is exactly why this went unnoticed here for two days — a global ignore file had been set up to silence the untracked-file noise, andgit statuswas then used as evidence that nothing was being written to the repo.Confirmed — reproduced on 2.1.233 (Linux, bubblewrap sandbox), regression since a security hardening in a late-June 2026 release (~v2.1.193).
What we observed, matching your report exactly: in a linked worktree with the sandbox enabled, each sandboxed Bash command creates a 0-byte, read-only
.git/config.lockin the shared.giton the host (it's the mount target for the read-only protection of the config file family, and to git its existence means "a config write is in progress"). While it exists,git configwrites fail witherror: could not lock config file …: File exists— inside the sandbox and in every other shell sharing that.git.On a clean exit the file is cleaned up, but an unclean exit (crash, kill, WSL shutdown) or a long-running sandboxed command strands it, and it then blocks all config writes in every worktree until removed by hand — and is re-materialized by each sandboxed command.
A fix that stops creating the lock file on Linux while keeping the
.git/configwrite protection is in review. Until it ships, deleting<main-repo>/.git/config.lockis safe whenever no realgit configwrite is in flight.🤖 Generated with Claude Code
Two measurements from a native Linux box, in case they bear on the scope of the fix in review.
The materialisation is not specific to
config.lock. On 2.1.238 (Debian 13, bubblewrap 0.11.0) the sandboxcreates the same 0-byte, mode 444 mount target for every protected path that is missing, at every ancestor of
the working directory. This box carries 60 of them:
.claude/settings.json,.mcp.json,.git/config,.git/worktrees, and home dotfile names such as.bashrc,.gitconfigand.ideasitting at project level.The clean-exit cleanup breaks a concurrent session. The confirmation above reads the clean exit as the safe
path. The cleanup counter is per process, so process A's tidy exit unlinks a placeholder that process B has
already read into its bwrap argument list. B's next call then dies with
bwrap: Can't find source path <path>, and that Bash command never runs. Cleaning up sooner, or more often,makes this failure more frequent.
I traced the mechanism, the two code fragments and three measured failures here:
https://github.com/anthropics/claude-code/issues/81602#issuecomment-5365483445
Would you check whether the fix in review reaches the general case? If it drops
config.lockfrom theprotected set alone, both effects above stay.