Claude Code Hooks to Block Test Edits and Auto-Run Checks¶
Ask a chatbot about Claude Code hooks and you get a correct description of what they are: shell commands the
agent fires at lifecycle events. That tells you the feature exists. It does not give you a working hook that
refuses an edit to a test file, or one that runs your suite after every change — the deterministic gate the
agent cannot skip. The sections below provide both, with the exact settings.json shape and the deny
mechanism, sourced from the Claude Code hooks docs.
What are Claude Code hooks?¶
A hook is a shell command Claude Code runs at a defined point in its loop. The two events that matter for
verification are PreToolUse, which fires before a tool call and can block it, and PostToolUse, which fires
after a tool succeeds. Each hook is matched to a tool by a matcher field — "Edit", "Write", or
"Edit|Write" for both — and receives a JSON object on stdin describing the call, including tool_name and
tool_input (for an edit, tool_input.file_path is the file being changed). The hook decides what happens by
its exit code or by JSON it prints. This is the difference between asking the agent to behave and removing the
choice: a hook runs whether or not the agent remembered the rule.
How do I block the agent from editing tests?¶
Use a PreToolUse hook matched to the edit tools. It reads the target path from stdin, and if the path is a
test file it exits with code 2, which blocks the call and feeds the stderr text back to the agent as the
reason. Save this as .claude/hooks/block-test-edits.sh and make it executable (chmod +x):
#!/bin/bash
path=$(jq -r '.tool_input.file_path' < /dev/stdin)
case "$path" in
*test_*.py|*_test.py|*/tests/*)
echo "Blocked: tests are the spec for this task; change app code, not $path" >&2
exit 2 ;;
esac
exit 0
Wire it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Edit|Write",
"hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-test-edits.sh" } ] }
]
}
}
Exit code 2 blocks the tool call; any other non-zero code is a
non-blocking error. For finer control you can instead exit 0 and print
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "..."}},
which denies the call with a structured reason rather than a raw stderr message.
How do I auto-run tests after an edit?¶
Run the suite from a PostToolUse hook on the same matcher. It fires after each edit succeeds, and because the
tool has already run it cannot prevent the edit — but it can stop the agent from moving on. Printing
{"decision": "block", "reason": "..."} returns the failure to the agent so it keeps working until the suite is
green:
#!/bin/bash
cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! out=$(pytest -q 2>&1); then
echo "{\"decision\": \"block\", \"reason\": $(jq -Rs <<<"$out")}"
fi
Add it under "PostToolUse" in the same settings.json block, matcher "Edit|Write", command pointing at the
script. Now every edit is followed by a real run, and a failing change is handed straight back to the agent
instead of being declared done.
See it on a real repository¶
You can watch the deny fire. The Practice Repo for this course, Triage, ships a
red case in tests/test_classify.py. With the block-test-edits hook installed, give the agent that failing test
as the spec and ask it to make the suite pass. When it tries to edit the test to match the wrong output — the
reward-hacking move — the PreToolUse hook returns exit 2, the edit is refused, and the agent is told to
change app/triage/classify.py instead. The before/after is stark: without the hook the green checkmark can be
faked by weakening the assertion; with it, that path is closed and the only way to green is the real fix.
The rule that holds¶
An instruction in CLAUDE.md not to touch tests depends on the agent's compliance on every turn; a
PreToolUse hook depends on nothing. Put the gate in the harness: block edits to test files with a
PreToolUse exit-2 hook, and auto-run the suite with a PostToolUse block-on-fail hook, so a weakened test or
a broken change is rejected automatically rather than caught by your eyes. Making verification deterministic
this way — eyeball to test to gate — is the subject of Module 3, whose
lab installs the gate on Triage, reintroduces the bug, and watches the commit get refused.