[BUG] Sandbox silently deletes project-root `refs/`, `objects/`, `HEAD` created mid-session — recursive, no prompt (macOS, 2.1.220)
Provenance: this report was investigated and written by Claude Code (Opus 5) during an agent session, and filed from @Semenar's account after review. The findings below come from experiments run in that session on 2.1.220 and from reading the bundled binary; the two claims that are source-derived rather than executed are labelled inline.
Preflight Checklist
- [x] I have searched existing issues (see Relationship to prior reports — all prior reports are closed, and the specific mechanism here is undescribed)
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
What's Wrong?
With sandbox.enabled: true on macOS, Claude Code recursively deletes refs/, objects/ and HEAD from the project root, with their contents, after any sandboxed Bash command — including a command that touches nothing. There is no prompt, no tool output, and no error. The project does not need to be a git repository.
The deletion is performed by Claude Code's own process via rmSync, outside the tool-call lifecycle, so no PreToolUse hook can intercept it.
The trigger is conditional, which is what makes it look intermittent: a directory that already exists when the sandbox config is built is merely write-denied and safe for the session. A directory created after that point is deleted. Same directory, same command, opposite outcome.
Steps to Reproduce
In any directory, with the sandbox enabled, on macOS. The directory need not be a git repo.
- Start a session and run any sandboxed Bash command (this builds the sandbox config lazily).
- Create the directory outside the sandbox, so creation isn't blocked:
````
mkdir -p refs && echo payload > refs/file.txt
- Run any sandboxed command, e.g.
echo hi. refs/andfile.txtare gone.
Observed directly:
05:00:29 create refs/file.txt (unsandboxed)
05:00:30..05:00:48 poll for 20s inside one call -> PRESENT throughout
05:01:01 sandboxed command, start -> PRESENT
05:01:04 sandboxed command, end -> PRESENT
05:01:18 next call -> GONE
Control results:
- Not time-based: survives 20 s of polling with no intervening tool call.
- Not caused by unsandboxed calls: survives any number of them.
- Any sandboxed command triggers it, including
echothat touches nothing. - Deletion happens at command teardown, so the directory is still present in the triggering command's own output.
Scope
Tested on 2.1.220 by creating each path, running one sandboxed echo, then checking:
| Path | Result |
|---|---|
| <root>/refs, <root>/objects | deleted recursively, contents included |
| <root>/HEAD (file) | deleted |
| <root>/HEAD (non-empty directory) | survives — the non-recursive rmSync throws and is swallowed |
| <root>/.git (directory) | survives, for the same reason |
| <root>/hooks, <root>/config | survive on 2.1.220 (see below) |
| <root>/nested/refs | survives — project root and cwd only |
| <root>/.refs, <root>/refs2 | survive |
Refs and REFS are also affected on case-insensitive filesystems.
Root Cause
In the bundled binary, the sandbox profile builder iterates the project root and cwd and, for v = ["HEAD","objects","refs"], records paths that are absent into Wnr — on macOS it adds a deny rule for them anyway:
let v = ["HEAD","objects","refs"], b = ["hooks","config"], C = (Lt()==="macos");
for (let K of D) { // D = { projectRoot, cwd, ...additionalDirectories }
for (let oe of v) {
let ce = js.resolve(K, oe);
try { yp.statSync(ce); c.push(ce); } // exists -> deny only
catch { Y = false; Wnr.push(ce); if (C) c.push(ce); } // MISSING -> deny + record in Wnr
}
for (let oe of b) { // hooks, config
let ce = js.resolve(K, oe);
try { ...c.push(ce)... } catch {} // missing -> not denied, never added to Wnr
}
Everything recorded in Wnr is then deleted after every sandboxed command:
function NGg(){
for (let e of Wnr) {
let t = e.slice(e.lastIndexOf(js.sep)+1);
try { yp.rmSync(e, { recursive: t!=="HEAD" && t!==".git" }),
w(`[Sandbox] scrubbed planted bare-repo file: ${e}`); } catch {}
}
}
// wired in as:
cleanupAfterCommand: () => { RT.cleanupAfterCommand(), NGg(), FGg() }
The intent is clear from the 2.1.78 changelog — "Fixed ... stub files polluting git status in the working directory". The sandbox planted stub files to hold deny rules for nonexistent paths, and this sweeps them away afterwards.
The defect is that NGg deletes whatever now occupies those paths without checking whether it is the stub it planted or a real directory the user created. It also never re-checks: Wnr is populated only by the config builder, which runs lazily on the first sandboxed command (telemetry event sandbox_exec_lazy_init), on settings changes, and on working-directory registration — not per command. So the snapshot goes stale and the sweep keeps firing against paths that are no longer stubs.
This also explains the conditional behaviour above, which is confirmed empirically: forcing a config rebuild (by touching any settings file) while refs/ exists moves it out of Wnr and into the deny list, after which it survives every subsequent sandboxed command and is merely read-only.
First Affected Version
Introduced in 2.1.78; 2.1.77 is clean. Established by bisecting published @anthropic-ai/claude-code releases for the scrubbed planted bare-repo file marker in package/cli.js (adjacent pair probed directly). The function's first form was unconditionally recursive: !0, with no HEAD/.git exemption; the exemption was added later.
Relationship to Prior Reports
This has been reported and closed four times without a maintainer response — every closure was automated:
- #25896 (2026-02-15) — root cause, deny list omits the
.git/prefix. Marked duplicate, closednot_planned. - #40568 (2026-03-29, v2.1.87) —
config/deleted. Labelledbug,has repro,data-loss; bot-closed as duplicate of #25896 the same day. - #52578 (2026-04-23, v2.1.118) — full
fs_usagetrace ofhooks/,HEAD,objects,refs,config. Bot-closed as duplicate of the already-closed #40568, then auto-locked. - #66288 (2026-06-08) — re-report. Closed 2026-07-15 as
not_planned/stale.
What is different now. Those reports centre on config/, because Rails and Elixir projects have one. On 2.1.220 config/ and hooks/ are no longer affected — I verified both survive. The remaining refs/objects/HEAD deletion is easy to mistake for fixed, and none of the prior reports identify the stale-Wnr mechanism that makes it fire only for directories created mid-session.
Impact
Silent recursive data loss in a non-git project directory, with no prompt and no audit trail. refs/ is a natural name for a references or citations folder; objects/ is common in asset and 3D pipelines. The failure mode is especially confusing for an agent, which sees its own writes vanish and retries in a loop — in the session that prompted this investigation, a set of downloaded PDFs was destroyed three times before the directory was renamed.
Workarounds
- Use a different name (
.refs,refs2) or nest one level (nested/refs). - If the name is required: create the directory, then touch any settings file to force a config rebuild. It then becomes read-only rather than deleted.
chflags -R uchg(from #40568) makes thermSyncfail.
A PreToolUse hook does not work — the deletion is an internal rmSync, not a Bash command.
Suggested Fix
Have NGg verify it is deleting its own stub before unlinking — record the inode/size of each planted stub and skip anything that no longer matches — or re-derive Wnr at cleanup time instead of reusing the init-time snapshot. Restricting the recursive delete to empty directories would also bound the damage.
Environment
- Claude Code 2.1.220 (VSCode extension native binary); behaviour present continuously since 2.1.78
- macOS, Darwin 24.5.0, arm64
sandbox.enabled: true
---
🤖 Investigated and drafted with Claude Code
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗