[FEATURE] No supported way to verify WHICH hook file will execute: settings is a claim, not a receipt (slot taken by another project's same-named entry; enforcement from a stale copy)
Summary
Claude Code can tell me a hook is configured. It cannot tell me which file that entry will actually execute, which settings file the row came from, or whether that file is the one I installed. Those are the three questions that decide whether a guardrail is real, and none of them is answerable from /hooks, from settings.json, or from the transcript.
The result is a class of failure where every visible signal is green and enforcement is zero. I have hit three variants of it on one machine, and I ended up writing a roughly 1,500-line audit command to answer the questions the harness does not.
This is a feature request, not a bug report. Nothing here is Claude Code doing the wrong thing; it is Claude Code not exposing enough for me to prove the right thing is happening.
How this differs from what is already reported
Closest neighbour is #81458 (hook launch failures exit 127 are silent and non-blocking), plus its comment thread, which extends to exit 141, to hooks that launch and exit 0 without evaluating anything, and to matcher/event mismatches. That issue's suggestion 3 asks /hooks to show last-execution status. I agree with all of it.
What #81458 and its thread ask about is execution outcome: did the process launch, did it decide anything. What I am asking about is one step earlier and orthogonal: identity and provenance of the artifact. Which absolute path does this row resolve to, who wrote this row, and does the bytes-on-disk match what I installed. A hook can pass every check in #81458 (launches fine, exits 2, blocks something) while being an entirely different script than the one I wrote tests for.
Also adjacent but not the same: #17361 (plugin cache never refreshes, so Claude reads a stale copy) is the plugin-scoped instance of "the copy that runs is not the copy you edited", reported as a cache bug rather than as a missing verification surface. #74942 and #6305 are "hooks do not fire" bugs.
The problem in one sentence
An entry in settings.json is a claim. There is no supported way to get a receipt.
Three variants, each measured on my own machine
1. A similarly-named entry from a different project occupies the slot
I run several unrelated checkouts on one machine, and more than one of them installs user-scope hooks into the same shared config root. Two of them shipped a hook script with the same basename. Installer B overwrote installer A's file at the shared path. settings.json did not change by one byte, and /hooks looked exactly as it had.
Sessions in checkout A then ran checkout B's script for hours. It exited 0 and printed nothing, which is byte-identical to a healthy hook with no work to do. I found it by accident.
On one machine my audit command later found a same-named hook script belonging to an unrelated project sitting at the shared user-scope path and wired in five separate config roots. Multiple config roots (I have several, because separate accounts get separate ~/.claude-* directories) multiply this: each root is its own opportunity for the wrong file to win, and nothing aggregates across them.
Minimal repro:
- Create two directories,
projA/andprojB/, each containing a script namedhook.ps1. Have A's printAand B's printBto a log file. - Copy
projA/hook.ps1to~/.claude/hooks/hook.ps1. Wire a user-scopeSessionStartentry whose command runs~/.claude/hooks/hook.ps1. - Start a session, confirm the log says
A. - Copy
projB/hook.ps1over~/.claude/hooks/hook.ps1. Change nothing else. - Run
/hooks. It is identical to step 3.
Actual: no surface distinguishes step 3 from step 5. Expected: some surface reports the resolved absolute path plus a content hash or mtime, so that a changed artifact under an unchanged config is visible.
2. Enforcement runs from a stale installed copy while the tests bind the repo copy
The pattern that makes this expensive is that installers copy a script out of a checkout to a path outside every working tree. After that, the checkout copy and the executing copy are two files that can drift in either direction.
I had a gate with dozens of passing tests. Every one of those tests invoked the script in the repo. Enforcement ran from an installed copy that was days behind. Reverse drift is equally invisible: delete a rule from the repo, and the stale installed copy keeps enforcing a rule the tests correctly report as gone.
Minimal repro:
- Copy
repo/gate.ps1to~/.claude/hooks/gate.ps1and wire it as aPreToolUsehook. - Add a new rule to
repo/gate.ps1. Add a test for it. Test passes. - Do not re-run whatever installed it.
- Start a session and exercise the new rule.
Actual: the rule does not fire, and every diagnostic available reports health. /hooks shows the hook configured, the test suite is green, settings.json is correct, and the hook does launch and exit 0. Expected: a way to see that the file at the wired path is not the file the tests bound.
3. Shims that re-resolve at run time, where "found nothing" and "nothing to do" are the same bytes
Some of my entries are deliberately not copies. The command is an inline snippet that locates a script under the current repository at invocation time and runs it if present, so a git pull updates the hook everywhere. That choice trades staleness for a different hole: if the script moves or is renamed, the snippet finds nothing and exits 0.
Measured on a worktree: both candidate bases returned false for both candidate paths, stdout was empty, nothing was written anywhere. That is byte-identical to a healthy hook with no peers, which is how one wired-but-resolving-nothing hook survived for weeks. It outlived every other silent defect I found the same day precisely because it printed a status line on every prompt when it did resolve; a status message is more convincing than silence, and every receipt it would have written lived inside the script the shim failed to find. Every possible check was strictly downstream of the failure it existed to detect.
Minimal repro:
- Wire a hook whose command is an inline snippet: resolve a path under the current repo, run it if
Test-Pathsucceeds, do nothing otherwise. - Rename the target script.
- Start a session.
Actual: silence, exit 0, no attachment in the transcript, /hooks unchanged. Expected: a way to ask what that command string resolved to on its last invocation, or under a dry run.
Actual vs expected, stated once
Actual: the only supported answer to "is this guardrail in force" is a settings read, which reports the text I typed back to me. Configuration is verifiable; the artifact that configuration names is not.
Expected: at least one surface that reports, per wired hook entry:
- the source settings file it came from (absolute path and scope), so an entry written by another project's installer into a shared root is attributable;
- the resolved target the command will execute (absolute path after shell and variable expansion), not just the command string;
- an identity fingerprint of that target (size, mtime, and a content hash), so a swapped or stale file is visible under an unchanged config;
- last invocation for that entry: which event and matcher consulted it, whether it matched, and what it returned, distinguishing "exited 0" from "returned a decision" (this part overlaps #81458 and its thread, and I would rather it be solved once than twice).
Two shapes that would each be sufficient for me:
claude hooks list --jsonemitting the above, runnable outside a session so it can go in CI and in an install script.- A probe:
claude hooks probe --event PreToolUse --tool Edit(or a--payloadfile) that runs the resolution the runtime actually uses and prints what came back, without executing the tool call. The important word is the resolution the runtime uses: a status check that finds the target by a better route than the hook's own reports a healthy hook that does not work. I made that exact mistake first.
What this cost
- Hours on the slot-occupancy incident, most of it spent trusting
settings.jsonbecause it was correct. - Weeks of a coordination hook running inert while printing a reassuring status line on every prompt.
- A gate enforcing a rule set days behind its own green test suite, for an unknown period.
- Roughly 1,500 lines of PowerShell whose entire reason to exist is answering these questions:
bin/ccx-doctor.ps1in a public MIT repo of multi-session tooling I maintain. It SHA-256-compares each installed copy against the checkout's source, reads live matchers out of every config root, diffs wired matchers against the rules the installed script implements (a rule implemented but never matched, or a matcher naming a rule the installed copy has never heard of, are both reported), models each shim's own resolution rather than a better one, and then fires each control with craftedPreToolUseJSON and requires it to refuse, paired with a negative control it must allow. - Every installer in that repo now writes its own receipt file next to the settings it edited (
ccx-coordination.receipt.json,ccx-hooks.receipt.json) and stamps a unique marker comment into each row it owns, purely so that "who wrote this entry" is answerable at all. Picking those markers has its own constraint I had to discover: two markers where one contains the other means one installer's cleanup deletes the other's hook. Seescripts/coord/install-coordination.ps1andscripts/worktree/install-gate.ps1. - The doctor's status vocabulary exists for the same reason:
OKis proven,REDis proven broken,OFFis implemented but invoked by nothing, and??is could-not-determine with its own exit code, because a skip must never read as a pass.
None of that is work I wanted to do, and none of it can see what the runtime sees. It infers from disk what the harness knows for certain.
Environment
- Windows 11, PowerShell 7
- Claude Code desktop app and the VS Code extension, several concurrent sessions against the same set of repositories
- Multiple client config roots on one machine (separate accounts), user-scope hooks shared across unrelated projects
- Hooks in use:
SessionStart,UserPromptSubmit,PreToolUse, plus git-sidecommit-msgandpre-pushshims
Why this is worth a first-class surface
The property that makes this class unusually costly is that every failure mode is byte-identical to success. Not installed, installed but not wired, wired but pointing at a script that no longer exists, wired at the wrong file, loaded but stale: all of them produce what a healthy hook with nothing to say produces, which is exit 0 and no output. There is no error, no warning, no degraded mode. Someone who clones a repo and runs one installer, or none, sits in exactly that state and will reasonably conclude the guardrails are working.
Every user solving this alone will build a worse version of the audit command, because only the runtime can say which file it is about to execute. One --json listing with resolved paths, source scopes and hashes would retire most of mine.
---
Corroborating code, if useful: the workarounds described above are implemented in
<https://github.com/wshallwshall/claude-multisession> (MIT, PowerShell 7 + Windows-first). Paths
cited in this report are relative to that repository. It is one user's tooling, not an endorsed or
official approach -- linked as evidence that the problem is real and what it costs to work around,
not as a recommendation.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗