claude-code - 💡(How to fix) Fix Bug: $CLAUDE_PROJECT_DIR becomes a stale pointer mid-session, silently disarming PreToolUse security hooks

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…

PreToolUse security hooks configured via the documented $CLAUDE_PROJECT_DIR/... form silently stop firing when a skill or user action deletes the directory $CLAUDE_PROJECT_DIR points at — most commonly when a worktree-merge skill runs git worktree remove --force on the session's worktree.

The harness sets $CLAUDE_PROJECT_DIR once at session start and never re-resolves. When the target is deleted, the path is still exported on subsequent hook invocations, the script can't be found, and the harness treats the resulting non-zero exit as a non-blocking error. The tool call proceeds.

For block-dangerous-* style hooks, this is a real security regression: the guard the user installed to prevent git push --force, prisma db push --env=production, etc., is offline for the remainder of the session with no visible signal beyond a stderr line buried in tool output.

Error Message

The harness sets $CLAUDE_PROJECT_DIR once at session start and never re-resolves. When the target is deleted, the path is still exported on subsequent hook invocations, the script can't be found, and the harness treats the resulting non-zero exit as a non-blocking error. The tool call proceeds. Actual: hook resolution fails with No such file or directory, harness classifies as non-blocking error, force-push proceeds.

Root Cause

The Stop / informational case ("No such file or directory" warning after worktree removal) is cosmetic. The PreToolUse block-dangerous-* case is a security regression: a user installs the hook precisely because they don't trust the model to avoid the dangerous command, then the guard silently goes offline. No alert, no surface in the session UI, command runs.

Fix Action

Fix / Workaround

That issue is the inverse: bare-relative paths drift; the recommended workaround is to use $CLAUDE_PROJECT_DIR/. This report describes the failure mode of that workaround. The two issues bracket the same underlying gap — there is no path-anchoring construct in hooks that is guaranteed-valid across both CWD drift and project-dir invalidation.

Either approach closes the failure mode without per-project workarounds and without changing observable semantics for any session where the project dir remains valid.

Workaround (deployed locally)

Code Example

{
     "PreToolUse": [{
       "matcher": "Bash",
       "hooks": [{
         "type": "command",
         "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous-git-commands.sh\""
       }]
     }]
   }
RAW_BUFFERClick to expand / collapse

Bug: $CLAUDE_PROJECT_DIR becomes a stale pointer mid-session, silently disarming PreToolUse security hooks

Summary

PreToolUse security hooks configured via the documented $CLAUDE_PROJECT_DIR/... form silently stop firing when a skill or user action deletes the directory $CLAUDE_PROJECT_DIR points at — most commonly when a worktree-merge skill runs git worktree remove --force on the session's worktree.

The harness sets $CLAUDE_PROJECT_DIR once at session start and never re-resolves. When the target is deleted, the path is still exported on subsequent hook invocations, the script can't be found, and the harness treats the resulting non-zero exit as a non-blocking error. The tool call proceeds.

For block-dangerous-* style hooks, this is a real security regression: the guard the user installed to prevent git push --force, prisma db push --env=production, etc., is offline for the remainder of the session with no visible signal beyond a stderr line buried in tool output.

Reproduction

  1. Start a session inside a worktree (claude -w <name>, or via EnterWorktree). $CLAUDE_PROJECT_DIR is set to the worktree path.
  2. Configure a PreToolUse hook in .claude/settings.json:
    {
      "PreToolUse": [{
        "matcher": "Bash",
        "hooks": [{
          "type": "command",
          "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous-git-commands.sh\""
        }]
      }]
    }
    where the hook script exit 2s on git push --force.
  3. Run a worktree-merge skill that ends with git worktree remove --force <worktree-path> (any custom or bundled merge skill). The worktree directory is deleted; $CLAUDE_PROJECT_DIR still points at it.
  4. Trigger the hook (e.g., have the model attempt git push --force).
  5. Expected: hook fires from the project's .claude/hooks/, exits 2, blocks the command. Actual: hook resolution fails with No such file or directory, harness classifies as non-blocking error, force-push proceeds.

The Bash tool's CWD-escape logic does handle the deleted-CWD case (you'll see Shell cwd was reset to <primary>). The hook subsystem has no analogous recovery for $CLAUDE_PROJECT_DIR.

Why this is in scope for a vendor fix

Docs gap

Per https://code.claude.com/docs/en/hooks:

${CLAUDE_PROJECT_DIR}: the project root. Both forms support the same path placeholders, and both export them as the environment variables CLAUDE_PROJECT_DIR, CLAUDE_PLUGIN_ROOT, and CLAUDE_PLUGIN_DATA on the spawned process

The docs:

  • Promote $CLAUDE_PROJECT_DIR as the anchor for hook script paths — no alternative is documented.
  • Do not specify when it's refreshed, implying stable-for-the-session semantics.
  • Document a separate CwdChanged event for "reactive environment management with tools like direnv" — implying $CLAUDE_PROJECT_DIR is the stable counterpart to a drift-able CWD.
  • Make no mention of what happens when the project dir is invalidated mid-session.

Users following docs verbatim land on this footgun. The same docs are how #50960 (linked below) recommends fixing CWD drift — making $CLAUDE_PROJECT_DIR the recommended path, and therefore the path with the largest blast radius when it goes stale.

Severity vs. cosmetic

The Stop / informational case ("No such file or directory" warning after worktree removal) is cosmetic. The PreToolUse block-dangerous-* case is a security regression: a user installs the hook precisely because they don't trust the model to avoid the dangerous command, then the guard silently goes offline. No alert, no surface in the session UI, command runs.

Related (sibling, inverse problem)

#50960CLI hooks: process CWD drifts away from project root mid-session, causing bare-relative hook commands to resolve against the wrong directory (Windows, 2.1.113) — open, labeled bug, has repro, area:hooks.

That issue is the inverse: bare-relative paths drift; the recommended workaround is to use $CLAUDE_PROJECT_DIR/. This report describes the failure mode of that workaround. The two issues bracket the same underlying gap — there is no path-anchoring construct in hooks that is guaranteed-valid across both CWD drift and project-dir invalidation.

Suggested fix

Either:

(a) Re-resolve $CLAUDE_PROJECT_DIR before each hook invocation. Cheap — walk up from the session's logical project, or git rev-parse --show-toplevel. Detects deletion automatically.

(b) Detect that the prior $CLAUDE_PROJECT_DIR no longer exists and pivot (e.g., if the deleted path is under .claude/worktrees/, pivot to the containing primary worktree). Symmetric with the existing Bash-tool CWD-reset logic.

Either approach closes the failure mode without per-project workarounds and without changing observable semantics for any session where the project dir remains valid.

Workaround (deployed locally)

For users hitting this before a vendor fix: route all project hook commands through a launcher script that lives outside the project dir (e.g., ~/.claude/hooks/run-project-hook.sh). The launcher resolves through $CLAUDE_PROJECT_DIR, falls back to git rev-parse --show-toplevel, and climbs out of a deleted .claude/worktrees/<name> to the primary repo. For security hooks (basename matches block-dangerous-* or via an explicit --missing=block flag), it exits 2 when the resolved hook is missing — tightening behavior beyond what's possible with the documented $CLAUDE_PROJECT_DIR/... form, since today a missing hook is a non-blocking warning.

Happy to share the full resolver if useful.

Environment

  • Claude Code: 2.1.149
  • Platform: macOS (but expected to affect all platforms — this is harness-level, not OS-specific)
  • Repro frequency: every time git worktree remove --force runs against the session's worktree

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 Bug: $CLAUDE_PROJECT_DIR becomes a stale pointer mid-session, silently disarming PreToolUse security hooks