Bench / Reading room

Mechanism

Claude Code PreToolUse hooks, explained.

A PreToolUse hook is a command Claude Code runs before executing a tool call, with the full details of that call delivered as JSON on stdin. The hook can inspect what is about to happen and allow it, block it, or escalate it to the user. It is the mechanism that makes binding governance of Claude Code's file writes possible, and it is what Bench is built on.

Holdings

  • PreToolUse hooks are configured in settings.json and fire before a matched tool call executes.
  • The hook receives the tool name and full tool input as JSON on stdin.
  • Exit code 2 blocks the call; the stderr text is fed back to Claude as the reason.
  • A hook can also print JSON with permissionDecision: allow, deny, or ask.
  • Hooks only see the tools they match. Shell redirection and MCP tools never reach a Write/Edit hook.

Where hooks are configured

Hooks live in Claude Code's settings files, at any of three scopes: ~/.claude/settings.json for every project, .claude/settings.json for one project (shareable), or .claude/settings.local.json for one project, locally only. A PreToolUse entry pairs a matcher, which selects tool names, with the commands to run:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "python /path/to/hook.py" }
        ]
      }
    ]
  }
}

The matcher matches tool names exactly, with alternation and regex supported: Bash, Write|Edit, or mcp__memory__.* for MCP server tools. An empty or * matcher matches every tool.

What the hook receives

When a matched tool call is about to run, Claude Code executes the hook command and writes one JSON object to its stdin. The fields that matter most for governance are tool_name and tool_input, the complete arguments of the proposed call:

{
  "session_id": "abc123",
  "cwd": "/home/user/my-project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/home/user/my-project/app.py",
    "content": "def handler():\n    ..."
  }
}

For a Write this includes the entire proposed file content; for an Edit, the old and new strings. The hook sees the change before it exists on disk, which is what makes pre-execution adjudication possible at all.

How a hook blocks a tool call

  1. Exit code 0: the hook raises no objection and the normal permission flow proceeds.
  2. Exit code 2: the call is blocked. Whatever the hook wrote to stderr is fed back to Claude as the reason, so the model can correct course and resubmit.
  3. Structured output: alternatively, exit 0 and print JSON containing hookSpecificOutput.permissionDecision set to "allow", "deny", or "ask", with an optional permissionDecisionReason. This grants, blocks, or escalates to the user explicitly.
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "C-001 violation: empty catch block swallows the error."
  }
}

The authoritative reference for the full payload and every hook event is Anthropic's own documentation: Claude Code hooks reference.

What hooks cannot see

A PreToolUse hook governs exactly the tools its matcher names, and nothing else. Three gaps matter in practice:

  • Shell writes. A file written through the Bash tool (redirection, tee, sed -i, a heredoc) is a Bash call, not a Write call. A Write/Edit hook never fires, and matching Bash instead does not give you the file content to adjudicate.
  • MCP server tools. A third-party tool named write_file is its own tool name. Unless your matcher anticipates it, it bypasses the hook, and it looks more legitimate than a shell command while doing so.
  • Anything outside the session. Edits made on GitHub, by bots, or by another human never pass through a local hook.

These boundaries are structural, not bugs. Any honest hook-based governance design names them up front, as Bench does in its Boundaries section.

From hook to governance

A hook is a veto point, not a judge. What you run inside it is the actual governance design. Bench uses its PreToolUse hook to convene a court: a Challenger surfaces evidence of violations against a declared constitution, a Defender argues the change's soundness, an Oracle issues a binding PASS or VETO, and the verdict is appended to a tamper-evident ledger. If the pipeline cannot rule, the change is denied. The whole implementation is open source at github.com/Nuralyn/Bench.