Skip to content

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.