claude-code - 💡(How to fix) Fix 2.1.118 silently rmdirs top-level hooks/, HEAD, objects, refs, config at project root on every Bash tool call [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#52578Fetched 2026-04-24 06:03:24
View on GitHub
Comments
1
Participants
2
Timeline
7
Reactions
0
Timeline (top)
labeled ×6commented ×1

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:

  • HEAD
  • objects/
  • 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.

Root Cause

  • [ 2] is ENOENT — HEAD, objects, refs, config don't exist at this project root, so those unlinkat calls are no-ops.
  • [ 1] is EPERM — unlinkat on hooks/ without AT_REMOVEDIR fails because it's a directory.
  • Claude Code then fstatat64s it (confirms it's a dir), openats it (flags decode to something like O_RDONLY | O_DIRECTORY | O_CLOEXEC), and issues a second unlinkat on hooks/ that succeeds. Recursive child-unlink syscalls are either coalesced by fs_usage or happen via the fd returned from openat so 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.

Fix Action

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:
    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.

Code Example

# 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

---

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

---

chflags -R uchg ~/projects/<proj>/hooks
# To edit later:
chflags -R nouchg ~/projects/<proj>/hooks
# …make changes…
chflags -R uchg ~/projects/<proj>/hooks

---

for p in ~/projects/*/; do
      for n in HEAD objects refs config; do
          [[ -e "$p$n" ]] && echo "AT RISK: $p$n"
      done
  done

---

mkdir -p ~/projects/claude-setup/hooks && <any Claude Code Bash call>; ls ~/projects/claude-setup/hooks
RAW_BUFFERClick to expand / collapse

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:

  • HEAD
  • objects/
  • 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, config don't exist at this project root, so those unlinkat calls are no-ops.
  • [ 1] is EPERM — unlinkat on hooks/ without AT_REMOVEDIR fails because it's a directory.
  • Claude Code then fstatat64s it (confirms it's a dir), openats it (flags decode to something like O_RDONLY | O_DIRECTORY | O_CLOEXEC), and issues a second unlinkat on hooks/ that succeeds. Recursive child-unlink syscalls are either coalesced by fs_usage or happen via the fd returned from openat so 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-setup is not special — it was just the first project where we had a legitimate top-level hooks/.
  • Most projects are silent victims: they have no HEAD / objects / refs / hooks / config at 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 convention hooks/<event>/<hook>.sh). HEAD / objects / refs / config as 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, no core.hooksPath set globally.
  • No external project codeai-personal-mentor, workflow-harness, the ralph-loop plugin, and grep across all ~/projects/ for references to claude-setup/hooks or similar deletion logic came up empty.
  • Not hardcoded to the claude-setup namestrings <claude-binary> | grep -c claude-setup returned 0.
  • Not a filesystem-watcher/daemon — when fs_usage was 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:
    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_usage evidence is identical: same HEAD → objects → refs → hooks → config unlink 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 denyWithinAllow list 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

  1. Report to Anthropic via the Claude Code issue tracker: https://github.com/anthropics/claude-code/issues. Include the reproduction above and the relevant fs_usage snippet.
  2. On each Claude Code upgrade, re-verify with:
    mkdir -p ~/projects/claude-setup/hooks && <any Claude Code Bash call>; ls ~/projects/claude-setup/hooks
    If hooks/ survives the next tool-call boundary, the upstream fix has landed.
  3. When fixed: rename hooks-src/hooks/, update the header comment in each deployed hook, remove the workaround row from CLAUDE.md / project.org, redeploy to ~/.claude/hooks/workflow/.

extent analysis

TL;DR

To avoid silent removal of specific directories and files by Claude Code 2.1.118, rename them to non-canonical names or apply the macOS immutable flag.

Guidance

  • Identify and rename any project root directories or files named HEAD, objects, refs, config, or hooks to avoid deletion.
  • Apply the macOS immutable flag to critical directories using chflags -R uchg <dir> to prevent accidental deletion.
  • Audit projects for affected files and directories using the provided bash script.
  • Consider committing critical directory contents to git for easier recovery in case of accidental deletion.

Example

To apply the immutable flag:

chflags -R uchg ~/projects/<proj>/hooks

To remove the flag for editing:

chflags -R nouchg ~/projects/<proj>/hooks

Notes

The provided workaround is specific to macOS. For Linux, the equivalent command is chattr +i <dir>, but it requires root privileges.

Recommendation

Apply the workaround by renaming critical directories or applying the macOS immutable flag until the issue is fixed in a future version of Claude Code.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

claude-code - 💡(How to fix) Fix 2.1.118 silently rmdirs top-level hooks/, HEAD, objects, refs, config at project root on every Bash tool call [1 comments, 2 participants]