Skip to content

Nothing asserts a declared hook actually fires — an unfired hook is indistinguishable from a passing one #15997

Description

@mrveiss

Problem

Nothing asserts that a declared hook actually runs. Every hook guard we have checks what a hook
does once invoked; none checks that it is invoked at all. A hook that never fires and a hook
that fires and passes are indistinguishable from the outside — both produce silence.

That is not hypothetical. The open hook bugs cluster on exactly this shape:

Issue How the hook stopped enforcing
#15961 core.hooksPath override silently disables every hook in a worktree
#15532 post-checkout rewrites itself mid-execution, shell resumes at a stale offset
#15446 branch-switch hook denies a form it cannot resolve, so it is worked around
#15512 gitignore-shadow hook reads one section, 44 paths invisible to it
#15369 / #15956 protect-files.sh ask() exits 2, so the decision is discarded

A live instance of the same class, found this session outside the repo: a Claude Code
PreToolUse hook declared with matcher: "Bash(git commit)". The matcher field is a tool-name
pattern — permission-rule syntax belongs in the separate if field — so as a regex it wants the
literal string Bashgit commit and never matches the tool name Bash. The hook had been dead.

Verified by observation, not by reading the config: a git commit run in the main working
tree on Dev_new_gui — which that hook blocks with exit 1 and the message
Commits from the main working tree are blocked — was not blocked. Git answered
nothing to commit instead. A guard whose whole purpose is to stop commits outside a worktree
had been silently absent.

This repo's own .claude/settings.json declares six hooks. If one of them acquired the same
defect tomorrow, nothing would report it.

Scope

A liveness check for declared hooks: for each hook entry in .claude/settings.json, assert that
a matching tool call actually reaches it. The mechanism that matters is a sentinel — invoke
the matching shape and require positive evidence the hook ran, rather than inferring it from the
absence of a failure.

Explicitly a reach check, not a behaviour check: whether each hook's own logic is correct is
what the existing per-hook issues cover. Presence of the expected signal, never absence of an
error — an unfired hook and a passing hook look identical otherwise.

Acceptance criteria

  • A test enumerates every hook entry in .claude/settings.json and asserts each is reachable by at least one matching tool call
  • A hook whose matcher cannot match any tool name fails the test, naming the entry and the reason
  • The test fails when a known-good hook entry is mutated to an unmatchable matcher (prove the check can fail)
  • Reach is proven by a positive sentinel per hook, not by the absence of an error
  • matcher vs if semantics are documented once, where hooks are declared, so the next author does not repeat the confusion
  • Findings for hooks that are already dead are filed separately rather than fixed inline here

Blast radius

Test-only plus a doc line; no runtime or product code path. The risk is a flaky reach check
becoming noise, which the "prove the check can fail" criterion is there to bound.

Related

Activity

  1. github-actions commented on Sep 7, 2026

    @github-actions
    Contributor

    👋 Auto-triage could not confidently classify this issue (no confident signal). Could a maintainer add the appropriate labels (frontend, backend, infrastructure, docs, testing and good-first-issue, intermediate, advanced)?

  2. added this to the v0.9.0 milestone on Sep 12, 2026
  3. added a commit that references this issue on Sep 17, 2026
  4. mrveiss commented on Sep 17, 2026

    @mrveiss
    OwnerAuthor

    Closed by PR #16890, merged to main. Each criterion verified against the merged tree.

    AC Evidence on merged main
    Enumerate every hook entry, assert each is reachable repo_tests/hook_declarations_are_reachable_test.py::test_every_declared_hook_can_be_selected, over all 6 entries in .claude/settings.json
    Unmatchable matcher fails, naming entry and reason findings read hooks.<event>[<i>]: matcher '…' <reason>, with two distinct reasons — is not a valid pattern vs matches none of the N known tool names
    Prove the check can fail four parametrized mutations of a real matcher (typo, malformed pattern, empty string, absent tool), plus a synthetic missing script and a matcher on a matcherless event
    Reach proven by a positive sentinel, not absence of error test_reach_is_proven_by_a_named_witness_not_by_silence requires a named witness tool per entry
    matcher vs if documented where hooks are declared .claude/hooks/README.md
    Dead-hook findings filed separately all four referenced scripts exist; nothing to file

    The mutation control earned its place on the first run — it failed, and caught a real defect in this guard. The original matched_tools tried the text before any ( whenever the whole matcher selected nothing, silently repairing the malformed Bash|Nonexistent( into a reachable Bash and reporting it healthy. A checker that repairs its input reports health it has not verified — this file's own subject, one level up. The fallback now matches the documented Name(...) shape strictly.

    One divergence recorded rather than resolved, in the README and unchanged by the merge: docs/developer/INSIGHTS_IMPROVEMENTS.md shows a Bash(git commit*) specifier form that no live entry uses and that I could not verify fires. It is written down as an open question rather than offered as an alternative, because a reader copying it could get a hook that never runs — which is this issue exactly. Anyone who knows the harness's behaviour can settle it in a line.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: git-hygieneWave 0 · cluster Y — Git hygiene & stranded workbugSomething isn't workingpriority: high

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions