Skip to content
All posts

Claude Code Hooks: The Control Plane for Autonomous Agents

Devdatta Narawade8 min read

Quick answer: Claude Code hooks are deterministic handlers at fixed lifecycle points. PreToolUse is the only hook that can stop a tool before it runs; exit code 2 blocks the call and sends stderr to Claude. Since v2.1.295, onFailure: "block" also blocks when the hook crashes, times out, or returns an unexpected code. Without it, your policy is fail-open.

The most consequential Claude Code release this month is one config line. Not a model, not a context window. onFailure: "block" changes what happens when the deterministic script between the agent and your filesystem fails. Before v2.1.295, that failure let the action through.

That is the difference between governance and theater. The model is probabilistic; a hook is not. Every rule about what an agent may touch belongs in the deterministic layer, because a prompt cannot enforce it.

What changed in October

onFailure makes failure a policy decision

A command or HTTP hook can fail before your policy runs: it cannot start, times out, or exits unexpectedly. The default was to continue. v2.1.295 (8 October) added onFailure: "block", so those failures stop the action (changelog). It does not apply to async hooks — the right design, since async hooks are for logging, not gates. At release, Anthropic's reference still described the old fail-open behavior for timeouts; version-gate the setting.

Mods move hooks in-process

v2.1.287 introduced Claude Mods, plugins that modify deeper behavior through in-process hooks. A mod exports register(on, options) and attaches handlers with on("event", handler); handlers can observe, rewrite, or answer with a denial (Mods reference). Events include prompt.submit, tool.call, agent.spawn, and since v2.1.300 prompt.change. agent.spawn can refuse a subagent or rewrite its model and prompt — a real control point for cost and blast radius.

The unattended run gets a watchdog

v2.1.301 (15 October) added CLAUDE_CODE_RETRY_WATCHDOG_MAX_USAGE_LIMIT_WAIT_MS, so unattended retry mode ends a request when a usage limit resets too far away instead of sleeping until it does. An agent at 3 a.m. needs both layers: hooks decide what it may do; the watchdog decides when the attempt is over.

Why fail-open hooks are governance theater

Exit codes are the contract

Exit 0 is silent success; Claude does not see stdout. Exit 2 is a blocking error, and stderr goes to Claude. Any other code is non-blocking and shown only to the user (hooks reference). A hook that detects a violation, prints a warning, and exits 0 has not governed anything. The block is exit 2 plus a reason the model can act on.

PreToolUse can also return JSON with permissionDecision (allow, deny, or ask), permissionDecisionReason, and updatedInput; additionalContext works on PostToolUse and UserPromptSubmit, not here.

The failures nobody tests

A hook can fail to launch (a path typo, a missing interpreter), time out, or exit unexpectedly (a missing dependency). Without onFailure, all three allow the action. With it, all three block it. A failure that blocks every call is an outage; one that allows every call is a breach. Keep the gate small, dependency-light, and observable.

There is also an exit-code collision. Issue #80697 reports that a hook which cannot launch may be read as a deliberate block — CPython exits 2 when it cannot open a script, the code reserved for "deny". The reverse has been reported too (#43407). Verify the gate on your version.

Where each control belongs

Tool boundary: PreToolUse

This is the enforcement point. Match state-changing tools — Bash, Edit, Write, and production MCP tools — and keep the rule set small. Start with recursive deletes outside the workspace, force-pushes, piping a download into a shell, credentials in arguments, and writes to .git/hooks, CI config, or deployment manifests. Use updatedInput sparingly; a rewrite is harder to audit than a denial.

Turn boundary: Stop and SubagentStop

A Stop hook can refuse to let the agent end its turn. Exit 2 with "tests failed" and Claude keeps working. This is where a definition-of-done gate belongs: typecheck, lint, the relevant test, and a check that the diff touches only allowed paths.

Do not let the agent grade its own homework. TestSuiteBench tested agents on 165 applications with roughly 74 mutants each. Their generated suites passed 88% of their own tests, yet mutation scores were below 60% — missing about 40% of specification-derived faults. Green tests prove the agent can write tests, not that the change is correct.

Session and evidence boundaries

SessionStart should record the working directory, CLI version, loaded settings, and a hash of the hook configuration; a changed hash is a review event. Refuse project hooks in repositories you do not trust.

PostToolUse runs after a successful call and cannot block it. Use it for formatting, diff capture, and SIEM events; PostToolUseFailure is separate. Log the rule, tool, input, and policy version for every block — without the last one, you cannot tell a regression from a deliberate change.

A fail-closed reference implementation

One gate on Bash, one done-gate on Stop, both fail-closed and time-bounded:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-guard.sh",
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/done-gate.sh",
            "timeout": 120,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

The guard script fails closed at every stage, not just at the policy check:

#!/usr/bin/env bash
# .claude/hooks/pre-tool-guard.sh
set -uo pipefail
 
payload=$(cat)
 
command -v jq >/dev/null 2>&1 || {
  echo "Hook dependency missing: jq. Blocking by default." >&2
  exit 2
}
 
cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // empty') || {
  echo "Hook could not parse tool input. Blocking by default." >&2
  exit 2
}
 
[ -z "$cmd" ] && exit 0
 
deny() {
  echo "$1" >&2
  jq -cn --arg reason "$1" \
    '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason}}'
  exit 2
}
 
case "$cmd" in
  *"rm -rf /"*|*"rm -rf ~"*) deny "Recursive delete of / or home is blocked." ;;
  *"git push --force"*|*"git push -f"*) deny "Force-push requires a human." ;;
  *"curl"*"| sh"*|*"curl"*"| bash"*) deny "Piping a download into a shell is blocked." ;;
esac
 
exit 0

The dependency check runs before the policy check, so a missing jq cannot allow everything. Every denial carries a reason in stderr and JSON, so the model can explain it and the log can group it. Add separate Edit/Write matchers with a path allowlist.

The security debt hooks do not solve

Project hooks are executable code

A repository can ship .claude/settings.json with a SessionStart hook; clone it, start Claude Code, and that command runs with your privileges. Check Point Research documented this class of issue and assigned CVE-2025-59536 and CVE-2026-21852; the reported issues were patched (Check Point).

The documentation gap matters more. An August 2026 docs issue asked whether project hooks are gated by workspace trust, and what happens under claude -p; the reference did not say, and a feature request reported repo-local hooks running without approval. Review the file and keep project hooks off in untrusted repositories.

Hooks are not a sandbox

A hook runs with your environment: the same files, ~/.ssh, cloud credentials. Blocking Bash does not block an MCP tool or a subagent with its own permissions. Run untrusted work in a container, scope credentials, and pin the CLI: the hook surface changed at least four times in October. For the wider threat model, see the security model.

A 30-day rollout plan

Week 1 — inventory and shadow mode. List every hook, with its event, matcher, timeout, and failure behavior. Run new rules in log-only mode and compare would-deny decisions with what happened.

Week 2 — enforce two rules. Choose the two failures that would hurt most: destructive filesystem operations and credential exfiltration. Add onFailure: "block", a ten-second timeout, and a reason for every denial. A noisy gate gets disabled, and a disabled gate looks like coverage.

Weeks 3–4 — prove the gate. Replay fixtures, including a missing dependency, malformed JSON, and a slow check. Add the Stop done-gate and test it against a failing branch. Track false positives, time to unblock, and escaped incidents before expanding to Edit/Write rules. The metric is not hook count; it is the share of state-changing actions behind a deterministic gate.

FAQ

What are Claude Code hooks?

Deterministic handlers at lifecycle points such as session start, around tool calls, and when a turn ends. They can be shell commands, HTTP endpoints, MCP tools, prompts, or in-process Mods.

What does onFailure: "block" do?

It makes a command or HTTP hook that cannot start, times out, or exits unexpectedly block the action. Added in v2.1.295; it does not apply to async hooks.

Do hooks replace permissions or sandboxing?

No. Hooks are policy, not isolation. They run with your privileges and cannot contain a compromised tool or plugin. Pair them with least-privilege credentials, workspace trust, and a container.

Why did my PreToolUse hook not block the call?

It exited 0 instead of 2; the matcher missed the tool; it was async or on PostToolUse, which cannot block; or it never launched and your version is fail-open.

Are project-level hooks safe?

Treat .claude/settings.json as executable code. A cloned repository can define hooks that run on session start with your credentials. Review it before opening an untrusted project.

The control plane you actually own

October's releases were about reliability, but the durable lesson is control. The one layer you can specify, test, and audit is the deterministic one.

Make every gate fail closed. Make every denial explain itself. Then let the agent run unattended — because you finally have something stronger than confidence.

For the cost side, see the cheap-subagent playbook; for the attack surface, see AI coding agent supply-chain attacks; for the rest of the October wave, see the release roundup; and for the harness, see the unbundled coding agent harness.

Sources

Related reading