Worktree guard fences all of /tmp when /tmp holds any `.git` entry (empty dir accepted as a repo root; resumed background sessions anchor under /tmp)

Status Open
Reported on v2.1.289
Maintainer reply None cached
Activity 0 comments · opened Oct 5, 2026

Version: Claude Code 2.1.289, Linux (WSL2), background sessions resumed through the daemon.

What happened

For four weeks, every Write/Edit to a path under /tmp from a resumed background session, and from all of its subagents, was refused with:

Edit the worktree copy of this file

There is no worktree copy of /tmp/<scratch>/file. Over 30 days we counted about 2,080 of these refusals across many sessions. Agents in those sessions recorded "subagents can't write under /tmp" as a rule and worked around it, for example by copying run directories into other locations.

The cause was a single empty directory at /tmp/.git. Git itself does not treat it as a repository:

$ mkdir /tmp/x && mkdir /tmp/x/.git
$ git --git-dir=/tmp/x/.git rev-parse --absolute-git-dir; echo $?
fatal: not a git repository: '/tmp/x/.git'
128

Moving /tmp/.git aside fixed new sessions immediately. Sessions that were already running kept refusing until they restarted.

Where the empty .git came from: the OpenAI Codex CLI sandbox

The Codex CLI's Linux sandbox protects the metadata names .git, .codex and .agents read-only under every writable root, and in workspace-write mode /tmp is a writable root by default. When a protected name does not exist, the sandbox mounts an empty read-only tmpfs on it (codex-rs/linux-sandbox/src/bwrap.rs). bubblewrap creates that mount point as a real directory on the host, and Codex removes it when the command finishes.

Reproduced with Codex 0.160.0. We ran this inside a private mount namespace so the host /tmp was never touched:

$ codex sandbox -P :workspace -C /tmp/work -- sleep 3 &
$ ls -A /tmp          # while it runs
.agents .aws .codex .git codex-bwrap-synthetic-mount-targets-0 codex-daemon-0 work
$ stat -c '%F %a' /tmp/.git
directory 755
$ ls -A /tmp          # after it exits
codex-bwrap-synthetic-mount-targets-0 codex-daemon-0 work

Our four-week leftover had the matching signature: /tmp/.git, /tmp/.codex and /tmp/.agents all shared one birth time, 197 ms after a Codex 0.153.2 session launched three parallel sandboxed commands. The Codex source has a comment noting that concurrent setups can leave an empty mount target behind. That leak is attributed, not reproduced.

A leftover like ours needs a race. The transient needs nothing: over the last 30 days, workspace-write Codex commands with /tmp writable ran for 7.0 hours on our host, or 0.98% of wall time, and without a leftover /tmp/.git exists for all of that time. A Claude Code session that resolves its root during one of those windows keeps the false root for the rest of its life (defect 3 below). Running both CLIs on one Linux machine is common, so other hosts will hit this. Codex's behavior is a deliberate protection. The defect is Claude Code accepting a .git that git itself rejects.

Three defects combine

  1. Any directory or regular file named .git is accepted as a repository root. That includes an empty directory or a junk file. Git rejects both shapes (exit 128). Validating HEAD, or the gitdir: line of a gitfile, the way git does would remove the false root.
  2. Resumed background sessions anchor on a spare directory under a hard-coded /tmp (/tmp/cc-daemon-<uid>/<hash>/spare; TMPDIR does not move it). On resume, the session's launch anchor falls back to the process cwd, which is that spare dir. The worktree escape check then adds the anchor's git root to the "shared checkout" roots, so a .git anywhere above the spare dir (/tmp/.git, /tmp/cc-daemon-<uid>/.git, ...) fences everything under it.
  3. Resolved roots are cached for the life of the process and never revalidated. Removing the stray does not help a session that already resolved it.

Expected

  • A .git that git rejects is not a repository root.
  • The daemon's spare directory is not treated as a launch checkout, so nothing above it can fence a resumed session.
  • The refusal names the root it resolved, for example "…because /tmp resolves to the git repository at /tmp/.git". An agent or operator can then see that the fence is an artifact and not a rule.

Repro (reconstructed from our investigation; not yet run end to end as a minimal repro)

  1. mkdir /tmp/.git (empty), or run any workspace-write Codex command so one exists during step 2.
  2. Start a background session, let it go idle, then resume it so the daemon runs it from its spare dir.
  3. Ask it to Write /tmp/scratch-<n>/a.txt. It is refused with "Edit the worktree copy of this file". A fresh foreground session in a normal repository writes the same path fine.

View original on GitHub ↗