🔒

Annette's Hub

Enter your password to access this audit

Incorrect password. Try again.

🪝 Systems Audit

Hook Audit:
Three Ways Hooks Die Quietly

2026-07-26  ·  18 hooks across user and project settings  ·  Claude Code 2.1.210

Why This Audit Happened

A Stop hook had been checking a deleted folder every session since May, and nobody noticed. That prompted the question: how many other hooks are firing when they shouldn't, or not firing at all?

The answer turned out to be two, and one of them was the hook that enforces this very page existing.

1

What Was Broken

The hub-first-check.py Stop hook, the one that enforces "publish to the hub before sharing," had never run. Not once. It was registered, the script existed, and it was silently doing nothing.

The runtime trace showed why:

python.exe: can't open file
'...\AnnettesHealth\Usersannet.claudehookshub-first-check.py'

The command was written as py -3.13 C:\Users\annet\.claude\hooks\hub-first-check.py, unquoted. Hook commands run through a POSIX shell, so every backslash was consumed as an escape character and the path collapsed into that garbage string. Python couldn't find the file, the hook failed, and nothing surfaced in normal use.

The fix: quote it and use forward slashes, matching the hooks that already worked.

py -3.13 "C:/Users/annet/.claude/hooks/hub-first-check.py"
Verified by re-running the trace: the error is gone, and the hook now fires correctly. It flagged its first real violation within minutes of being repaired, which is how this page came to exist.

The second casualty was session-stop-drift-check.py, which watched three Desktop files deleted back in May. That one has been retired along with the whole desktop bookkeeping system.

2

The Three Failure Modes

These are genuinely different problems, and no single check finds all three.

ModeWhat it looks likeWhy it stays hiddenDetected by
Bad quoting Script exists, command is malformed, hook never runs Config looks correct on inspection. The corruption happens in the shell. Static sweep
Dead targets Script runs fine, but the files it operates on are gone Nothing errors. The base folder still exists, so it just processes nothing. Static sweep, but only if it resolves runtime-composed paths
Fail-open silence Hook breaks, swallows its own error, writes no log Invisible by construction. This is the correct design, and the cost of it. Runtime trace only
Two more worth knowing about
  • UTF-8 BOM in a settings file. A single invisible byte at the start makes Claude Code reject the entire file, killing every hook and every permission inside it. PowerShell's Out-File adds one by default. Use Set-Content -Encoding utf8NoBOM.
  • Orphaned hooks.json. Hooks are only loaded from the hooks key inside settings.json. A standalone .claude/hooks/hooks.json is never auto-loaded. Four documented hooks sat in one for nearly three months and never fired once.
3

How To Check, Two Layers

Layer 1: static sweep, run monthly
py -3.13 C:\Users\annet\ClaudeProjects\MyExecAssistant\scripts\audit_hooks.py

Reads every hook across user settings and all project settings, then classifies each one. It reconstructs paths that scripts build at runtime from variables, which is what the naive version missed. It also flags BOMs and orphaned hook files.

  • DEAD the script does not exist, this hook can never succeed
  • BROKEN unquoted Windows path, the shell eats the backslashes
  • STALE script runs but the files it watches are gone
  • COLD writes a log or state file that has not changed in 30 days
  • DUPE the same hook is registered twice for one event
  • UNVERIFIED fails open and writes no log, so a break would be invisible
Layer 2: runtime trace, the only real proof
claude --debug hooks --debug-file trace.log -p "reply with just: ok"

Then search the log for error, canceled, can't open file, and Failed to parse hook output. This is the layer that caught the broken quoting, and no amount of config reading would have found it.

The rule underneath both: never report a hook as working because it appears in settings. Check for its side effect, or trace it.
4

Current State

ResultCountMeaning
Total hooks18Across user settings plus 3 projects
DEAD / STALE / BROKEN0All repaired or retired
Unreadable settings files0No BOMs
Orphaned hooks.json0Previously fixed
UNVERIFIED6Working, but cannot self-report
About those 6

UNVERIFIED means "cannot be proven statically," not "broken." They all fail open and write no log, which is the right default (a broken hook should never block a session), but it means config inspection can never confirm them. Two pieces of evidence say they are fine: the skill-of-the-day hook demonstrably produced output, and the post-fix trace showed every Stop hook completing without error.

To make them self-reporting, each would need one line appending a timestamp to a log on success. The COLD check would then catch any that go quiet. That is a small, optional upgrade, not currently done.

One open question

A claude-mem plugin SessionEnd hook reported "canceled" in both traces. That may simply be an artifact of headless mode shutting down abruptly rather than a real defect. Not called a bug until it is seen in an interactive session.

Artifacts

Auditor: MyExecAssistant/scripts/audit_hooks.py, regression-tested against the known-broken drift-check script and unit-tested on the quoting detector.
Gotcha writeup: the hooks section of ~/memory/topics/tooling-gotchas.md, so this bites at most once.
Retired: catchup-indexes skill and session-stop-drift-check.py, archived with a written rationale.
Reference: Claude Code hooks documentation, and the BOM issue at claude-code#9906.

Internal systems audit. Findings verified by live runtime trace on Claude Code 2.1.210, not by configuration inspection alone.