2.1.118 silently rmdirs top-level hooks/, HEAD, objects, refs, config at project root on every Bash tool call
_Filed against Claude Code 2.1.118 on macOS Darwin 25.3.0. This is the active-data-loss companion to #25896 and a re-report of #40568 (closed by duplicate-detection bot, then auto-locked; see Related Issues below for why this needs a fresh issue rather than a comment)._
Claude Code 2.1.118 — Silent rmdir of hooks/, HEAD, objects, refs, config at Project Root
Summary
Claude Code 2.1.118 silently removes a specific set of entries from the root of the active project after every Bash tool call. The targeted names are exactly the canonical contents of a bare Git directory:
HEADobjects/refs/hooks/config
If any of these exist at the project root, they are deleted (files via unlinkat, directories recursively). If they don't exist, the attempts are no-ops (ENOENT). In most real projects only hooks/ is common enough to routinely get hit, but every project is affected the same way — the behaviour just doesn't show unless you put a qualifying name there.
The process issuing the syscalls is the Claude Code node/bun binary itself (2.1.118.<pid> as it appears in fs_usage output), confirmed via lsof +D <project> showing the same PID with .claude/settings.local.json open.
Reproduction
One-liner, in any local git project:
# terminal A — start filesystem trace (sudo required for fs_usage)
sudo fs_usage -w -f filesys 2>/dev/null | grep -Ei "claude-setup/hooks|rmdir|unlink.*claude-setup"
# terminal B — inside a Claude Code session in that project, run any Bash tool call:
mkdir -p ~/projects/<any-project>/hooks/workflow
touch ~/projects/<any-project>/hooks/workflow/bait
# …within ~1s of the next tool-call boundary, `hooks/` is gone.
ls ~/projects/<any-project>/hooks
# => No such file or directory
Timing: the wipe fires ~100ms–1s after each Bash tool call returns, not at turn boundaries. It runs on every tool call, not just the ones that touched hooks/.
Evidence
Representative fs_usage output captured during a live wipe (external terminal, sudo):
01:33:32.260760 mkdir [17] /Users/patrick/projects/claude-setup/hooks mkdir.1078371
01:33:32.260763 mkdir [17] /Users/patrick/projects/claude-setup/hooks/workflow mkdir.1078371
01:33:32.264186 fstatat64 /Users/patrick/projects/claude-setup/hooks/workflow/bait ls.1078375
...
01:33:32.269545 unlinkat [ 2] /Users/patrick/projects/claude-setup/HEAD 2.1.118.836636
01:33:32.269565 unlinkat [ 2] /Users/patrick/projects/claude-setup/objects 2.1.118.836636
01:33:32.269568 unlinkat [ 2] /Users/patrick/projects/claude-setup/refs 2.1.118.836636
01:33:32.269571 unlinkat [ 1] /Users/patrick/projects/claude-setup/hooks 2.1.118.836636
01:33:32.269574 fstatat64 /Users/patrick/projects/claude-setup/hooks 2.1.118.836636
01:33:32.269594 openat F=21 (R_______F__X___) /Users/patrick/projects/claude-setup/hooks 2.1.118.836636
01:33:32.269730 unlinkat /Users/patrick/projects/claude-setup/hooks 2.1.118.836636
01:33:32.269739 unlinkat [ 2] /Users/patrick/projects/claude-setup/config 2.1.118.836636
01:33:32.271276 unlink /private/tmp/claude-501/-Users-patrick-projects-claude-setup/…/tasks/bdk95fcng.output 2.1.118.836680
Interpretation, syscall by syscall:
[ 2]is ENOENT —HEAD,objects,refs,configdon't exist at this project root, so thoseunlinkatcalls are no-ops.[ 1]is EPERM —unlinkatonhooks/withoutAT_REMOVEDIRfails because it's a directory.- Claude Code then
fstatat64s it (confirms it's a dir),openats it (flags decode to something likeO_RDONLY | O_DIRECTORY | O_CLOEXEC), and issues a secondunlinkatonhooks/that succeeds. Recursive child-unlink syscalls are either coalesced byfs_usageor happen via the fd returned fromopenatso they don't appear under the parent path. - The very next line is a legitimate unlink inside Claude Code's per-project state dir at
/private/tmp/claude-501/-Users-patrick-projects-<proj>/…/tasks/*.output. That is the dir this cleanup should be operating on.
The sequence fired twice in the same trace (01:33:32.269 after one Bash call ended and 01:33:40.386 after the next), confirming "once per Bash tool call" cadence.
Root Cause Hypothesis
Claude Code maintains a per-project task/cache state store under /private/tmp/claude-<uid>/-<encoded-project-path>/<session-uuid>/.... The state dir includes a git-object-store-like structure with HEAD / objects/ / refs/ / hooks/ / config entries, and a teardown routine that removes them on each tool-call boundary.
The teardown's base-path resolution is wrong: instead of rooting at the state dir it roots at the project working directory. So it tries to unlinkat HEAD/objects/refs/hooks/config relative to the project root. The legitimate targets under /private/tmp/claude-*/.../tasks/*.output in the same syscall burst are strong corroboration — the cleanup is doing its intended unlinks in the right dir, AND an extra erroneous pass in the wrong dir.
This is a path-resolution bug, not sandbox behaviour, not a hook.
Scope
- Any Claude Code 2.1.118 project is affected the same way;
claude-setupis not special — it was just the first project where we had a legitimate top-levelhooks/. - Most projects are silent victims: they have no
HEAD/objects/refs/hooks/configat the root, so the wipe is a syscall-level no-op. - Real hazard: projects that do have those names at the root.
hooks/is the most likely collision (e.g., repos mirroring~/.claude/hooks/layout, repos with hook source directories, projects following the conventionhooks/<event>/<hook>.sh).HEAD/objects/refs/configas root-level files would also be silently destroyed — rarer but not impossible.
Ruled-Out Causes (investigation trail)
Before identifying the Claude Code binary as the culprit, the following were eliminated:
- None of the 33 registered hook scripts across PreToolUse / PostToolUse / Stop / SubagentStop / SessionStart / SessionEnd / PreCompact / Notification / UserPromptSubmit / PermissionRequest do any deletion matching the pattern.
- No launchd agent in
~/Library/LaunchAgents/runs at the right cadence.com.harness.sweep(the closest candidate) runs every 15 min; wipe is per–tool-call. - No git hook —
.git/hooks/has only samples, nocore.hooksPathset globally. - No external project code —
ai-personal-mentor,workflow-harness, theralph-loopplugin, and grep across all~/projects/for references toclaude-setup/hooksor similar deletion logic came up empty. - Not hardcoded to the
claude-setupname —strings <claude-binary> | grep -c claude-setupreturned 0. - Not a filesystem-watcher/daemon — when
fs_usagewas running WITHOUT an accompanying Claude Code Bash tool call, no wipe occurred. The wipe only fires when Claude Code is actively processing a tool call.
Workaround
Two layered options:
1. Rename (zero-friction). Do not use the canonical hooks/ name at a project root under Claude Code 2.1.118. For this repo, the source copy of deployed hooks lives at hooks-src/workflow/ — see CLAUDE.md and the header comment in hooks-src/workflow/security-scan.sh. Any name not in the delete list (hooks-src/, .hooks/, claude-hooks/, scripts/hooks/, etc.) persists.
2. macOS immutable flag (if rename isn't acceptable). Per the discussion on the prior closed report (see Related Issues below), chflags -R uchg <dir> makes unlinkat fail with EPERM, so Claude Code's wipe is silently rejected at the OS level and your directory is preserved:
chflags -R uchg ~/projects/<proj>/hooks
# To edit later:
chflags -R nouchg ~/projects/<proj>/hooks
# …make changes…
chflags -R uchg ~/projects/<proj>/hooks
Linux equivalent (needs root): chattr +i <dir>. Workflow-hostile but bulletproof.
General guidance for any project:
- Audit every project for root-level files named
HEAD,objects,refs,config. Quick scan:
``bash``
for p in ~/projects/*/; do
for n in HEAD objects refs config; do
[[ -e "$p$n" ]] && echo "AT RISK: $p$n"
done
done
- If you must keep a root-level
hooks/, commit its contents to git. Working-tree wipe doesn't remove git objects;git checkout HEAD -- hooks/restores after each wipe. Still painful, not recommended.
Related Issues
- anthropics/claude-code#40568 (closed, locked) — same bug observed against
config/directory in an Elixir project on Claude Code 2.1.87.fs_usageevidence is identical: sameHEAD → objects → refs → hooks → configunlink burst, same Claude Code binary attribution. Auto-closed by a duplicate-detection bot as a duplicate of #25896, then auto-locked 7 days later. Lock notice explicitly invites a new issue if the bug is still observed. - anthropics/claude-code#25896 (open) — describes the sandbox
denyWithinAllowlist having the.git/prefix missing from the same path set (HEAD,objects,refs,hooks,config). Same root cause (a path-construction routine somewhere is forgetting the.git/segment) but the observable effect is different: #25896 is the sandbox not protecting.git/internals from writes; this report and #40568 are Claude Code actively unlinking those names from the project root. Two distinct code paths inheriting the same wrong base path. Fixing the underlying path-construction would presumably resolve both.
The persistence of this bug across at least Claude Code 2.1.87 → 2.1.118 (about a month and ~25 versions) suggests the duplicate-bot closure of #40568 may have masked it from the team's triage. Reopening visibility is worthwhile.
Fix Path
- Report to Anthropic via the Claude Code issue tracker:
https://github.com/anthropics/claude-code/issues. Include the reproduction above and the relevantfs_usagesnippet. - On each Claude Code upgrade, re-verify with:
``bash`
mkdir -p ~/projects/claude-setup/hooks && <any Claude Code Bash call>; ls ~/projects/claude-setup/hooks
hooks/` survives the next tool-call boundary, the upstream fix has landed.
If
- When fixed: rename
hooks-src/→hooks/, update the header comment in each deployed hook, remove the workaround row fromCLAUDE.md/project.org, redeploy to~/.claude/hooks/workflow/.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗