Last month Claude Code told me it had added a regression test. The commit landed. The PR merged. Two days later a bug slipped through — the test wasn't actually there. The agent had claimed the work, generated a convincing diff summary at the end of the turn, and the tool-call log looked clean. But git log -p showed nothing new in the test file.
That is a specific failure mode worth naming. The model doesn't hallucinate in the "makes up an API" sense. It hallucinates in the "narrates work that didn't happen" sense. Edits silently drop. write_file collapses to a no-op because the content it generated already matched the file. Branch names drift. And the summary at the end of the turn confidently reports what should have happened, not what actually did.
I built a small trust-but-verify layer that catches these at the hook boundary instead of in production. Four hooks, pure bash, zero runtime dependencies. Here's how each one works, and the one non-obvious thing I learned while writing it.
The one Claude Code hook contract fact everything hangs on
Claude Code hooks have an exit-code contract you can use to intervene before and after tool calls. The part worth memorizing is this:
- Exit 0 — allow the tool call, swallow stderr.
- Exit 2 — block the tool call, and feed stderr back to the model as a correction prompt.
Exit 2 is the whole trick. Your hook doesn't just log a complaint — it tells the model, in-band, why its action was rejected and what to do instead. The model sees your stderr the way it would see any other tool result, and corrects. The agent doesn't need to know a hook exists; it just experiences a world where some actions get a clear "that didn't work, here's why" response.
Once you internalize that, you stop writing "warnings" and start writing hooks that shape the agent's next move.
Note on the snippets. Claude Code hooks receive their input as JSON on stdin (not as
$CLAUDE_TOOL_INPUT— thanks to @skillselion in the comments for catching an earlier draft that got this wrong). The snippets below show the real stdin-reading pattern; the full source in the repo usespython3 -cfor the trickier parsing but the shape is the same.
Hook 1: verify-write
A PostToolUse hook on Edit and Write. It checks whether the tool call produced any actual change in the working tree.
# simplified; the real hook reads the Claude Code hook payload on stdin.
payload="$(cat)"
file="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // .tool_input.path // empty')"
staged_before="$(cat "$pre_tool_diff_file" 2>/dev/null)" # written by a sibling PreToolUse
staged_after=$(git diff HEAD -- "$file")
if [ "$staged_before" = "$staged_after" ]; then
echo "verify-write: the edit of $file produced no change." >&2
echo "If the file already contained what you intended, say so explicitly instead of re-editing." >&2
exit 2
fi
Three failure modes it catches in practice:
-
The no-op edit. The model's
old_stringandnew_stringare identical modulo whitespace, so the file writes identical bytes. - The convinced-already edit. The content the model "wanted" to add is already in the file (common after a prior rewrite), so the apply succeeds but changes nothing.
-
The wrong-file edit. The model edits a backup or a
.bakand still reports "updated X".
The non-obvious part: you can't just compare bytes before and after the tool call. Normalization matters. A trailing-newline-only diff is almost always semantic noise and should not count. A whitespace-only diff is a judgment call — I treat it as a real diff by default, because if the model intended a whitespace fix, that's work. But I expose a config flag so a team can tighten it.
Hook 2: block-wrong-branch
A PreToolUse hook on Bash that matches git push and checks whether the current branch is in an allowlist.
payload="$(cat)"
cmd="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty')"
if [[ "$cmd" =~ "git push" ]]; then
branch=$(git rev-parse --abbrev-ref HEAD)
allowed=$(jq -r '.allowed_push_branches[]' .claude/config.json 2>/dev/null)
if ! grep -qx "$branch" <<< "$allowed"; then
echo "block-wrong-branch: refusing to push $branch." >&2
echo "This repo only pushes from: $(echo $allowed | tr '\n' ' ')" >&2
exit 2
fi
fi
I wrote this after Claude Code pushed what it believed was a feature branch, which was actually main, because it had checked out a different branch halfway through the session and lost track. The hook refuses the push and tells the model what the allowlist is — so the next thing it does is check out the right branch, not re-attempt the same push.
Hook 3: scope-fence
A SessionStart hook that writes a declared-scope file (.claude/.current-scope) and a PreToolUse hook on Edit/Write that rejects any edit to a path outside the fence.
# PreToolUse — hooks receive the full payload on stdin, so pull tool_input from there.
payload="$(cat)"
target=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty')
fence=$(cat .claude/.current-scope 2>/dev/null)
if [ -n "$fence" ] && ! [[ "$target" =~ ^($fence)$ ]]; then
echo "scope-fence: $target is outside the declared scope." >&2
echo "Current scope: $fence" >&2
echo "If this edit is intentional, update the scope explicitly." >&2
exit 2
fi
The fence is a regex set during session start — "today I'm touching src/auth/.* and tests/auth/.*". When Claude Code decides mid-session that it also needs to refactor the ORM layer, the hook catches it. The point isn't that cross-cutting edits are always wrong. The point is that they should be acknowledged. If the agent has to re-declare the scope out loud, I at least get to see it coming instead of discovering it in the diff three turns later.
Hook 4: audit-turn
A Stop hook (runs at the end of a turn, after the final assistant message). It parses the last message for first-person claims — "I added …", "I fixed …", "the tests now pass" — and checks each one against reality:
- "I added
X" → doesgit diffshow additions inX? - "The tests now pass" → was there a successful test run in the tool-call log for this turn?
- "I pushed to
branch" → doesgit log origin/branch..HEADshow the commit?
Mismatches get logged to .claude/trust-audit.log and exit 2 — which, for a Stop hook, refuses the stop and feeds stderr back to Claude, so the agent gets one more turn to actually make the change or correct the claim. (The Stop payload carries stop_hook_active=true the second time around; the hook honours it and lets the stop go through, so an unverifiable claim can't loop forever.) The log also feeds a trust-report CLI that prints a rolling claim-match ratio per repo per week.
I started at 71% in the first week. After two weeks of the first three hooks nudging the agent toward actually doing what it said, the ratio climbed to 94%. Not because the model got better — it's the same model — but because the hooks remove the low-friction path to "lying by narration".
The one surprising lesson
Writing the no-op detector was the hardest part, and I didn't expect that. Deciding what counts as a "real change" has three or four edge cases that each deserve their own test:
- whitespace-only edits
- trailing-newline-only edits
- semantically equivalent reformats (e.g. the model moves an import, nothing else)
-
write_filethat recreates an identical file after aread_file
I ended up shipping 26 integration tests just for the four hooks and the install script, because I couldn't trust the hooks until I'd seen them pass and fail on known inputs. If you build your own, test them. The whole point is to be the thing you trust when you've stopped trusting the model's self-report; your hooks have to earn that trust.
Install
If you want to try it on an existing repo, I've packaged the four hooks plus the trust-report CLI as Did-It-Actually. The install script safely merges into .claude/settings.json with a backup, so you can reverse it. MIT license, works with Claude Code ≥ 1.0.60.
Launch discount for #showdev readers: SHOWDEV50 cuts it to $9.50 — first 25 copies, through Oct 21. The link above applies the code automatically.
If you'd rather just read the no-op detector and crib the idea, the public repo on GitHub has the full source.
Happy to answer questions in the comments — how I handle false positives on whitespace, how the audit-turn parser handles conditional claims ("I think this should pass"), or how to adapt the scope fence for monorepos where "scope" is multiple packages at once.
Top comments (8)
On audit-turn, "the turn is already over" undersells what a Stop hook can do. Per the Claude Code hooks docs, exit 2 from a Stop hook blocks the stop and feeds stderr back to Claude, so the agent keeps working instead of ending the turn. That means audit-turn could reject "I added a regression test" when
git diffshows nothing new in the test file, and make the agent fix it in the same turn instead of logging it for next week's report. The catch is loops: the Stop input carriesstop_hook_active, and you check it so a second refusal cannot run forever. It also carrieslast_assistant_message, so the claim parser can read the final message without opening the transcript. Separate question: the snippets read$CLAUDE_TOOL_INPUT, but hooks receive their input as JSON on stdin. Does your installer set that variable?Three things, worst first.
The $CLAUDE_TOOL_INPUT snippet was just wrong. The shipped hooks actually read payload="$(cat)" and parse the JSON with python3
you can see it in verify-write.sh and audit-turn.sh. That snippet came from an earlier draft I’d rewritten for readability and never re-diffed against the real script. Fixed the article, added a note at the top, and credited you for catching it.
On the Stop hook: you’re right, I undersold it. Exit 2 does block the stop and feed stderr back, so audit-turn can keep the agent working in the same turn instead of just logging the problem and moving on. The shipped v1.5 of audit-turn already exits 2, but, and this is a real miss, it has no stop_hook_active guard. A single claim that can’t be verified would refuse the stop on every turn and lock the session up. Fixing that in v1.6: read stop_hook_active off the payload and exit 0 early if it’s set.
On last_assistant_message: right now the hook falls back to walking the transcript file, because that’s what I knew about when I wrote it. Reading the field straight off the Stop input is simpler and sidesteps the “transcript_path doesn’t exist” issue I keep hitting on fresh projects. Switching to that in the same patch, keeping the transcript walk as a fallback for older session shapes.
Didn’t expect this much scrutiny in week one. appreciate it.
"Narrates work that didn't happen" is exactly the right name for it, and the write_file no-op is the nastiest case because every log looks clean. We added one more check in the same spirit: the end-of-turn summary is compared with the actual diff, and any claim the diff doesn't support is read back to the agent before the turn closes. Has the branch-drift hook caught real cases for you, or is it mostly the test-claim one?
Great framing ("narrates work that didn't happen"). One trap with exit-2 guards: if the hook itself crashes, times out or exits 1, Claude Code treats that as a hook error and the call carries on through normal permissions, so a broken guard fails open. Since 2.1.295 you can set
"onFailure": "block"on command hooks so they fail closed, which is worth turning on for the branch and scope guards. Test it by renaming the script and confirming the call is blocked.set -euo pipefailin the bash also helps, so a missing jq doesn't quietly pass.install.sh in v1.5 already sets "onFailure": "block" on all three command hooks, for exactly this reason. It was one of the first things I wired in after 2.1.295 shipped that field. Before that there was no clean way to fail closed without an exit-2 wrapper catching every error path. I should have called it out in the post.
Good call on the rename test, don’t have it yet. Plan: rename the script, replay a no-op Edit fixture, check it’s denied, rename back. Adding it.
set -euo pipefail is already first line in every hook. What worries me is python3 silently missing. audit-turn.sh’s parser is a python here-doc, so no python means no error even with -e. Adding a command -v python3 check, fail closed if missing. Same for jq, good call. I’ll tighten those checks too.
A check that reads the agent's own transcript to decide whether the work happened will pass whatever the agent wrote. Test the claim against what the tools changed instead: the diff, the branch, the exit code of the test run.
Yeah, that's the annoying part. The hooks catch structural stuff – no-op edits, commits to the wrong branch, files that were never written. But if Claude just summarizes work it didn't finish, that slips through. Wrote about exactly that in Day 6.
@98dkcc488bglitch The hooks see the shape of the work. A summary describes its result, and only the result can be set against the diff.