Mert Özen Further With Every Line
Artificial Intelligence Claude Code — #07

Claude Code Hooks: Enforcing Rules, Formatting and Running Tests Automatically

Mert Özen Nov 15, 2026 13 min 4 views
Claude Code Hooks: Enforcing Rules, Formatting and Running Tests Automatically

The way to enforce a rule in Claude Code is a hook, not CLAUDE.md: Claude Code runs your script at a set moment, and exit code 2 or a deny decision stops the tool call. The event, matcher and handler structure, stdin JSON, exit codes, writing hooks for Windows in PowerShell, and for Bookstore a guard for committed migrations, formatting the changed file, shortening test output and a Stop check.

Version note: This post describes Claude Code 2.1.325 (13 November 2026); hook events, the input and output format, exit codes and Windows behavior were checked on 15 November 2026 against the Automate actions with hooks and Hooks reference pages at code.claude.com/docs. This post is based on the official documentation. The hook scripts were run with PowerShell 7.7 against sample JSON input, the dotnet format step was tried with the .NET 10 SDK, and settings.json was parsed locally as JSON.

Guess First

When Emre asked Claude Code to fix a column type in Bookstore, the online bookstore used as the shared example across this site's series, he saw that it edited the InitialCreate migration committed three weeks earlier. Yet CLAUDE.md had the line "Never edit an existing migration", and the ask rule from the third post asked on every migration edit; Emre had answered "yes" quickly. The next day he wrote a PreToolUse hook, the script failed and exited with exit 1, and Claude edited the same file again.

What is your guess? Why did a hook that ended with exit 1 not stop the action? And does a hook written for Edit|Write see it if Claude changes the migration file with a shell command? The first answer is in the exit code section, the second in the Stop hook.

We said in the second post that CLAUDE.md is an instruction, not an enforced rule. A hook is Claude Code running your command at a given moment: it looks at the script's exit code, not at the model's decision. Tool-independent team rules are in the AI-Assisted Development series; here we build the mechanism in Claude Code.

Event, Matcher and Handler

A hook has three layers. The event is when the hook runs: PreToolUse before a tool call, PostToolUse after a successful call, Stop when Claude finishes its answer, SessionStart when a session opens, UserPromptSubmit before the prompt reaches Claude, Notification when Claude Code is waiting on you. The list has more than thirty events, such as SubagentStop, PreCompact, WorktreeCreate and InstructionsLoaded. The matcher is a filter within the event: on tool events it looks at the tool name (Edit|Write, Bash|PowerShell, mcp__.*). The handler is what runs: usually a command, but it can also be an HTTP endpoint, an MCP tool, or a prompt handler that asks a model.

Hooks live under the hooks key in settings files: your personal ~/.claude/settings.json, the team's committed .claude/settings.json, the personal .claude/settings.local.json, managed settings, plugins, and skill or subagent frontmatter. The levels don't override each other; they merge. Hooks that match the same event run in parallel; one returning deny doesn't stop the others.

Input, Exit Codes and JSON Output

Claude Code passes the event's data as JSON on the script's stdin. The common fields are session_id, cwd and hook_event_name; tool events add tool_name and tool_input. For Edit, Write and Read, tool_input.file_path is always an absolute path, and on Windows it arrives with backslashes.

The script answers with its exit code. 0 means "no objection"; on PreToolUse that is not an approval, and the call continues through the normal permission flow. 2 blocks, and whatever you write to stderr goes to Claude as the reason. Every other exit code is a non-blocking error: a notice appears in the transcript and the action continues. That is the answer to the first question: the exit 1 we are used to from Unix doesn't enforce a policy here. The documentation also recommends exit 2 for a hook that enforces a rule.

For more control you exit 0 and print a single JSON object. On PreToolUse, hookSpecificOutput.permissionDecision takes four values: deny cancels the call and gives the reason to Claude, ask asks you, allow skips the prompt, and defer, only in claude -p, leaves the call for later. Even when you return allow, deny and ask rules still apply; when several hooks decide differently, the most restrictive one wins.

Bookstore: Protecting Committed Migrations

In the third post we recommended a permission rule for a hard block, but there is a distinction the rule can't see: fixing a migration just created with /add-migration and not yet committed is normal; changing a committed migration, perhaps already applied to the production database, is not. A path pattern can't know the difference; git can. .claude/hooks/protect-migrations.ps1:

# PreToolUse (Edit|Write): blocks edits to committed migration files; new ones stay editable.
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
$path = [string]$payload.tool_input.file_path
if (-not $path) { exit 0 }

# Windows sends backslashes; compare with forward slashes.
$normalized = $path.Replace('\', '/')
if ($normalized -notmatch '/src/Bookstore\.Api/Data/Migrations/') { exit 0 }
if (-not (Test-Path -LiteralPath $path)) { exit 0 }

$name = Split-Path -Leaf $path
git -C (Split-Path -Parent $path) ls-files --error-unmatch -- $name *> $null
if ($LASTEXITCODE -ne 0) { exit 0 }

[ordered]@{
    hookSpecificOutput = [ordered]@{
        hookEventName            = 'PreToolUse'
        permissionDecision       = 'deny'
        permissionDecisionReason = "$name is a committed migration file. Never edit it; add a new migration with /add-migration."
    }
} | ConvertTo-Json -Depth 5 -Compress
exit 0

If the file doesn't exist yet (a new migration through Write) or git doesn't know it, the script exits quietly. If git knows the file, it returns deny; the reason goes to Claude, and Claude turns to creating a new migration. BookstoreContextModelSnapshot.cs is committed too, so it is protected; only dotnet ef should update it. You can try the hook without Claude Code, from the repository root, with a sample input:

$payload = @{
    tool_name  = 'Edit'
    tool_input = @{ file_path = (Resolve-Path "src/Bookstore.Api/Data/Migrations/$file").Path }
} | ConvertTo-Json -Compress
$payload | pwsh -NoProfile -File .claude/hooks/protect-migrations.ps1

With $file set to the committed 20261101120000_InitialCreate.cs the output is as below; for the new, uncommitted 20261115090000_AddLowStockAlerts.cs the output is empty, and the exit code is 0 in both cases:

{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"20261101120000_InitialCreate.cs is a committed migration file. Never edit it; add a new migration with /add-migration."}}

Because the hook makes this distinction, we removed the Edit(/src/Bookstore.Api/Data/Migrations/**) ask rule from the third post from settings.json. appsettings.Production.json needs no hook of its own: according to the Configure permissions page, a Read deny rule also blocks Edit and Write calls on the same path, and the Read(**/appsettings.Production.json) rule from the third post is already in place. For a ban that only looks at the path, the right tool is a permission rule; keep hooks for logic a rule can't express.

Bookstore: Formatting the Changed File

A PostToolUse hook runs after the tool has run, so it can't block anything, but it can tidy up afterwards. .claude/hooks/format-file.ps1 formats only the file Claude changed:

# PostToolUse (Edit|Write): formats only the file Claude just changed.
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
$path = [string]$payload.tool_input.file_path
if (-not $path -or -not (Test-Path -LiteralPath $path)) { exit 0 }

$root = git -C (Split-Path -Parent $path) rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -ne 0) { exit 0 }

Push-Location $root
try {
    $relative = (Resolve-Path -LiteralPath $path -Relative) -replace '^\.[\\/]', ''
    if ($relative -match '\.cs$') {
        $output = dotnet format whitespace . --folder --include $relative 2>&1
    }
    elseif ($relative -match '^web[\\/]bookstore-web[\\/].*\.(ts|html|scss)$' -and
            (Test-Path -LiteralPath 'web/bookstore-web/node_modules/prettier')) {
        Push-Location 'web/bookstore-web'
        $output = npx prettier --write $path 2>&1
        Pop-Location
    }
    else {
        exit 0
    }
    if ($LASTEXITCODE -ne 0) {
        [Console]::Error.WriteLine("format failed for ${relative}: $output")
        exit 1
    }
}
finally {
    Pop-Location
}
exit 0

dotnet format whitespace --folder applies only the whitespace rules from .editorconfig without loading the project; for a command that runs after every edit, speed matters. We leave the full style and analyzer check to dotnet format --verify-no-changes in CI. On the Angular side, Prettier runs only if it is installed in bookstore-web's node_modules; the script doesn't try to install the package itself. Given a sample Edit input for a badly formatted BookEndpoints.cs, the hook changed the file like this:

diff --git a/src/Bookstore.Api/Catalog/BookEndpoints.cs b/src/Bookstore.Api/Catalog/BookEndpoints.cs
index f9f03ab..1cf1036 100644
--- a/src/Bookstore.Api/Catalog/BookEndpoints.cs
+++ b/src/Bookstore.Api/Catalog/BookEndpoints.cs
@@ -2,5 +2,5 @@ namespace Bookstore.Api.Catalog;

 public static class BookEndpoints
 {
-public static string Title(string isbn)   =>    isbn;
+    public static string Title(string isbn) => isbn;
 }

If formatting fails, the script exits with exit 1: a deliberately non-blocking error, because the edit has already happened and a failure to format shouldn't stop the work.

Bookstore: Shortening Test Output and Checking at Stop

PostToolUse can replace the tool output that goes to Claude with updatedToolOutput. Most of a successful dotnet test output is build lines; what Claude needs are the warnings and the summary. .claude/hooks/short-test-output.ps1:

# PostToolUse (dotnet test that exited 0): keeps warnings, errors and the last lines.
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
$response = $payload.tool_response
if (-not $response -or -not ($response.PSObject.Properties.Name -contains 'stdout')) { exit 0 }

$lines = @(([string]$response.stdout) -split "\r?\n")
if ($lines.Count -le 60) { exit 0 }

$important = @($lines | Where-Object { $_ -match '\b(error|warning|failed)\b' } | Select-Object -First 40)
$tail = @($lines | Select-Object -Last 15)
$kept = $important + $tail
$header = "[output shortened by .claude/hooks/short-test-output.ps1: kept $($kept.Count) of $($lines.Count) lines]"
$response.stdout = (@($header) + $kept) -join "`n"

[ordered]@{
    hookSpecificOutput = [ordered]@{
        hookEventName     = 'PostToolUse'
        updatedToolOutput = $response
    }
} | ConvertTo-Json -Depth 10 -Compress
exit 0

A distinction in the documentation sets the limit of this hook. PostToolUse runs only after a successful tool call; according to the documentation, a command that ends with a non-zero exit code fires the PostToolUseFailure event instead, and that event can't replace the output, only add additionalContext. So this hook doesn't shorten the long output of red tests; that is the job of the test-runner subagent from the sixth post. The script also does nothing when tool_response has no stdout field, because the documentation gives the output shape for Bash and we don't want to guess it. The header line in the replaced output is deliberate too: Claude should know what was cut.

The answer to the second question is here. The Edit|Write matcher doesn't run when Claude changes the file with a shell command such as Set-Content or sed. The documentation suggests a Stop hook that scans the working tree at the end of each turn. .claude/hooks/stop-guard.ps1 checks two things: whether a committed migration changed, and, if C# files changed, whether the tests are green:

# Stop: keeps Claude working while committed migrations are modified or the API tests are red.
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
$root = git -C $payload.cwd rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -ne 0) { exit 0 }

function Block([string]$reason) {
    if ($payload.stop_hook_active) {
        # Already continued once because of this hook: tell the user, let Claude stop.
        [ordered]@{ systemMessage = "stop-guard: $reason" } | ConvertTo-Json -Compress
    }
    else {
        [ordered]@{ decision = 'block'; reason = $reason } | ConvertTo-Json -Compress
    }
    exit 0
}

# 1. Committed migrations changed by any tool, shell commands included.
$migrations = @(git -C $root diff --name-only --diff-filter=MD HEAD -- 'src/Bookstore.Api/Data/Migrations' |
    Where-Object { $_ -notmatch 'ModelSnapshot\.cs$' })
if ($migrations.Count -gt 0) {
    Block ("Committed migrations were changed: " + ($migrations -join ', ') +
        ". Restore them with git restore and add a new migration instead.")
}

# 2. API tests, only when C# files changed since the last green run.
$changes = git -C $root status --porcelain -- '*.cs' '*.csproj'
if (-not $changes) { exit 0 }
$diff = (git -C $root diff HEAD -- '*.cs' '*.csproj') + ($changes -join "`n")
$sha = [System.Security.Cryptography.SHA256]::Create()
$hash = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($diff))) -replace '-', ''
$stamp = Join-Path ([IO.Path]::GetTempPath()) "bookstore-green-$($payload.session_id).txt"
if ((Test-Path -LiteralPath $stamp) -and ((Get-Content -LiteralPath $stamp -Raw).Trim() -eq $hash)) { exit 0 }

$output = dotnet test (Join-Path $root 'Bookstore.slnx') --nologo 2>&1
if ($LASTEXITCODE -eq 0) {
    Set-Content -LiteralPath $stamp -Value $hash
    exit 0
}
$failures = @($output | Where-Object { "$_" -match '\b(error|failed)\b' } | Select-Object -First 15)
Block ("dotnet test failed (exit $LASTEXITCODE). Fix these before finishing:`n" + ($failures -join "`n"))

On Stop, decision: "block" keeps Claude from stopping, and reason becomes its next instruction. There are two protections against an endless loop: stop_hook_active in the input JSON is true when Claude is already continuing because of a Stop hook, and in that case the script shows you a systemMessage instead of blocking. In addition, after Stop hooks have extended the turn eight times in a row, Claude Code ignores the next block. Storing a hash of the changes also makes the tests run only when C# code changed, not after every answer.

Wiring It into settings.json, and Windows Notes

We wire the four scripts into a single hooks key next to the permissions block in the team's committed .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "shell": "powershell",
            "command": "& \"$env:CLAUDE_PROJECT_DIR\\.claude\\hooks\\protect-migrations.ps1\"",
            "onFailure": "block",
            "timeout": 30
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "shell": "powershell",
            "command": "& \"$env:CLAUDE_PROJECT_DIR\\.claude\\hooks\\format-file.ps1\"",
            "timeout": 60,
            "statusMessage": "Formatting the changed file"
          }
        ]
      },
      {
        "matcher": "Bash|PowerShell",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(dotnet test *)",
            "shell": "powershell",
            "command": "& \"$env:CLAUDE_PROJECT_DIR\\.claude\\hooks\\short-test-output.ps1\""
          },
          {
            "type": "command",
            "if": "PowerShell(dotnet test *)",
            "shell": "powershell",
            "command": "& \"$env:CLAUDE_PROJECT_DIR\\.claude\\hooks\\short-test-output.ps1\""
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "shell": "powershell",
            "command": "& \"$env:CLAUDE_PROJECT_DIR\\.claude\\hooks\\stop-guard.ps1\"",
            "timeout": 600,
            "statusMessage": "Checking migrations and API tests"
          }
        ]
      }
    ]
  }
}

"shell": "powershell" runs the hook in PowerShell on Windows; Claude Code looks for pwsh.exe first and falls back to Windows PowerShell 5.1. That is why we wrote the scripts to work on 5.1 as well and used ConvertFrom-Json instead of jq. For the project root write $env:CLAUDE_PROJECT_DIR; a bare $CLAUDE_PROJECT_DIR is an undefined variable in PowerShell and comes out empty. The if field narrows a handler with permission rule syntax but takes a single rule, which is why there are two handlers, one for Bash and one for PowerShell. Writing the matcher as Bash|PowerShell is required, because on a Windows machine without Git Bash there is no Bash tool at all.

"onFailure": "block" on the guard hook matters: according to the documentation, if a hook can't start, times out or exits with a code other than 0 or 2, the action continues on most events. A guard whose script path you mistyped lets everything through; block stops the call in that case. The default timeout for command hooks is 600 seconds; we gave that time to the Stop hook on purpose and shortened the others.

Verifying and Debugging

/hooks opens a read-only browser that shows which file each hook comes from and exactly what it runs. For detail, start with claude --debug and read the debug log; since v2.1.296, command hooks on tool events and on events such as Stop leave a line with their duration in the log, which is where you find a slow hook. To turn all hooks off temporarily there is "disableAllHooks": true, but there is no way to keep a single hook in the configuration and switch it off.

In the second post we promised a hook to see when path-scoped rules load. The InstructionsLoaded event runs when a CLAUDE.md or .claude/rules/*.md file enters the context and can't block anything; it is for observation only. In your personal .claude/settings.local.json:

{
  "hooks": {
    "InstructionsLoaded": [
      {
        "hooks": [
          {
            "type": "command",
            "shell": "powershell",
            "command": "$p = [Console]::In.ReadToEnd() | ConvertFrom-Json; Add-Content -LiteralPath \"$env:TEMP\\bookstore-instructions.log\" -Value \"$($p.load_reason) $($p.memory_type) $($p.file_path)\""
          }
        ]
      }
    ]
  }
}

The load_reason in the log tells you whether the file loaded at startup (session_start), because a path pattern matched (path_glob_match), or because of an import (include).

In Production

Command hooks run with your user permissions and can read and delete every file you can access. Review hooks that come from a repository like code. According to the documentation, in an interactive session hooks are held back until the workspace trust dialog is accepted for the folder, but claude -p and Agent SDK runs have no such dialog and treat the folder as trusted: hooks in a repository's .claude/settings.json run even in a folder you never trusted. Before running claude -p in a repository you didn't write, read its .claude/ folder or pass --settings '{"disableAllHooks": true}' for that run.

Hooks run inside subagents too; edits from the sixth post's subagents also go through the migration guard. On the organization side, an administrator can allow only the hooks in managed settings with allowManagedHooksOnly; those settings are in the eleventh post.

Keep hooks fast. PreToolUse runs before every Edit and Write; a slow script slows every step. Move heavy checks to Stop or to CI.

Common Pitfalls

"But I wrote it in CLAUDE.md." An instruction is context; an enforced rule is a permission or a hook.

exit 1 in a policy hook. It doesn't block; use exit 2 or permissionDecision: "deny" and add onFailure: "block".

Watching only Edit|Write. To see changes made with shell commands, scan the working tree at Stop.

Comparing a Windows path with forward slashes. file_path arrives with backslashes; a comparison without normalizing never matches, and the hook silently lets everything through.

A JSON field at the wrong level. The PreToolUse decision goes inside hookSpecificOutput, while the decision of Stop and PostToolUse goes at the top level; a field in the wrong place has no effect.

A Small Experiment

★☆☆ Adapt protect-migrations.ps1 to a folder in your own project and run it with the PowerShell snippet above for a committed and a new file. Compare the two outputs.

★★☆ Write a Notification hook that shows a notification on Windows when Claude is waiting for approval. Confirm it appears in /hooks and ask for something that needs approval in Manual mode.

★★★ Add the Angular side to stop-guard.ps1: run npm test when .ts files under web/bookstore-web changed. Keep a separate hash for each side and measure from the duration lines in the debug log whether a turn runs anything unnecessarily.

With hooks we put Claude Code's own tools under control. The next step is giving it new tools: connecting the C# MCP server from the ninth post of the AI Agents and MCP series, a read-only SQL Server connection, and a browser for testing the Angular UI to Claude Code. The eighth post covers claude mcp add, scopes and .mcp.json.