Section 19 of 45 4 min read

Hooks: One Deterministic Control

Three levels of control, what belongs in a hook, where hooks fail open, and the layered position that follows.

Objective

Establish the distinction between asking and enforcing, and show what the enforcement layer looks like.

19.1 Four Levels of Control #

LevelMechanismRuns when the model does not cooperate?
Prompts askInstructions, skills, subagent personasNo
Grants boundTool lists, permissions, sandboxesYes, but only at the capability boundary
Hooks enforceEvent-triggered codeYes, unconditionally
Platform policy governsEnterprise-managed settings, organization policyYes, and the repository cannot disable it

A hook is a program the harness executes on a defined event—before a tool call, after a file write, before a commit. It executes regardless of what the model decided, and its exit code can block the action.

Among the mechanisms a repository sets for itself, a grant can remove a capability and a hook can enforce a rule; everything else is a preference. The platform layer is stronger still, since nothing committed to the repository can disable it, and §20.4 covers what belongs there.

19.2 What Belongs in a Hook #

The test from §14.2 applies: must this hold whether or not the model cooperates? The hook below answers yes for one narrow case—writes to generated files, vendored code, lockfiles, and migrations already on disk—and it runs on every tool call regardless of what the model intended.

#!/usr/bin/env bash
# .claude/hooks/pre-tool-use.sh
# Blocks writes to protected paths regardless of what the model intends.
set -euo pipefail

payload="$(cat)"                                   # hook receives JSON on stdin
tool="$(jq -r '.tool_name' <<<"$payload")"
path="$(jq -r '.tool_input.file_path // empty' <<<"$payload")"

[[ "$tool" == "Write" || "$tool" == "Edit" ]] || exit 0
[[ -n "$path" ]] || exit 0

case "$path" in
  */migrations/[0-9]*)
    # Only a migration that already exists is immutable. A new numbered file
    # is the sanctioned repair, so blocking it would make the advice in the
    # denial below impossible to follow.
    [[ -e "$path" ]] || exit 0
    ;;
  */src/generated/*|*/vendor/*) ;;
  # Lockfiles, enumerated. `*.lock` alone catches yarn.lock, Cargo.lock,
  # poetry.lock and Gemfile.lock and silently misses every lockfile whose
  # name does not end that way — which includes npm's.
  */package-lock.json|*/npm-shrinkwrap.json|*/pnpm-lock.yaml|*/bun.lockb) ;;
  */composer.lock|*/Pipfile.lock|*/uv.lock|*.lock) ;;
  *) exit 0 ;;
esac

jq -n --arg p "$path" '{
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason:
      ("Writes to \($p) are blocked by policy. Generated files, " +
       "vendored code, lockfiles and existing migrations are not " +
       "editable by an agent. Regenerate, or add a new migration.")
  }
}'
exit 0

Two properties of that hook generalize to any other you write. The block reason is written for the model, not for a log—it explains what to do instead, which turns a block into redirection rather than a dead end. And the hook fails open on unrecognized shapes (exit 0 when the payload does not match), which is a deliberate choice: a hook that crashes on unexpected input and takes the session with it is worse than one that lets an edge case through. Where the risk profile inverts that trade-off, fail closed and say so explicitly.

Three caveats ride along with the patterns. Each leading */ requires a separator before the directory name, so they assume the absolute paths Claude Code’s own Write and Edit pass; src/generated/x.ts arriving bare would not match. Reused anywhere paths may be relative, they need bare-prefix alternatives too.

The lockfile names are enumerated rather than matched by suffix, and that is the correction most such hooks need. *.lock reads like a category and is a string test: it catches yarn.lock, Cargo.lock, poetry.lock and Gemfile.lock, and it silently passes package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml and bun.lockb—so a policy written to protect lockfiles left the npm ecosystem’s entirely unprotected, with no error to say so. Enumerate the names your repositories actually contain, and keep *.lock as the tail case rather than the rule. And presence on disk stands in for applied, which is as close as a pre-tool-use hook reaches without querying the database, so a migration written but not yet run is protected alongside the ones that have been.

19.3 Where Hooks Fail Open #

Hooks are the strongest control available, and they are nevertheless not absolute in three respects:

They are configuration, and configuration is editable

A hook in a repository can be modified by anything with write access to the repository, including an agent. Hooks protecting against agent behavior should live in enterprise-managed configuration where the agent cannot reach them, or be enforced server-side in CI.

They cover the events the harness emits

If a capability is reachable by a path with no hook event, the hook does not apply. Enumerate the events your surface actually supports before assuming coverage.

A crash is a gap

A hook that errors is frequently treated as a pass. Test hook failure modes deliberately.

19.4 The Layered Position #

The correct architecture employs all four levels from §19.1, with CI behind them, each matched to consequence.

Instruction file:  "Do not edit generated files."          ← states intent
Tool grant:        subagent tools: [read_file, grep]       ← removes capability
Hook:              blocks Write to src/generated/**        ← enforces regardless
Platform policy:   org rule the repository cannot edit     ← enforces un-removably
CI check:          fails the build if generated files      ← catches everything
                   differ from a fresh generation             the above missed

Five layers cover five distinct failure modes. The instruction handles the cooperative case cheaply. The grant handles the case where a subagent should never have had the capability. The hook handles the case where the model tries anyway. Platform policy handles the case where the hook itself is edited away, which §19.3 names as the first thing a repository-level control cannot defend against; §20.4 covers what to set there and §38.5 and §40.4 show two vendors’ implementations. CI handles the case where every layer above it was bypassed or misconfigured.

Section 36 revisits this stack from the adversarial side, where the ordering matters more.

PDF↓