Claude Code Hooks: Deterministic Guardrails for Agentic Workflows

0
Claude Code Hooks: Deterministic Guardrails for Agentic Workflows

Here is the problem with putting an instruction in CLAUDE.md: it is guidance, not enforcement.

You can write:

Always run the formatter after editing a file.

Claude will usually follow it. On a short, supervised task, usually may be enough. On a long autonomous run, it is not. Claude can overlook the instruction, lose it during context compaction, or decide that another action is more important.

A hook changes the nature of the rule. Instead of asking the model to remember an action, you attach executable logic to a fixed point in the Claude Code lifecycle.

A PostToolUse hook can run the formatter after every successful edit. A PreToolUse hook can inspect or block a command before it executes. A Stop hook can refuse to let Claude finish while tests are failing.

That is the core value of hooks:

CLAUDE.md describes what Claude should do. Hooks control what the surrounding runtime actually does.

Hooks are deterministic automation, but they are not automatically safe or infallible. A badly written hook can fail open, modify the wrong file, or run a dangerous command with your own permissions. The guarantee comes from placing well-tested code at the correct lifecycle event and making policy hooks fail closed.

Where hooks fit in the agentic loop

The current Claude Code hook reference documents 30 events across the session lifecycle. You do not need to memorize all of them.

Most practical hook setups are built around a much smaller group:

Event When it fires Typical use
SessionStart When a session starts, resumes, clears, forks, or continues after compaction Inject repository state, configure environment variables, restore working context
UserPromptSubmit After the user submits a prompt but before Claude processes it Add dynamic context or reject invalid prompts
PreToolUse Before a tool call executes Allow, deny, escalate, or rewrite a tool call
PermissionRequest When a tool call requires a permission decision Automate approvals for narrowly defined safe operations
PostToolUse After a tool call succeeds Format files, run focused lint checks, record audit events
PostToolUseFailure After an executed tool fails Add corrective context, collect diagnostics, alert external systems
SubagentStop When a subagent finishes Validate subagent output before accepting completion
Stop When Claude wants to finish its turn Run tests or refuse completion while required checks fail
PreCompact Before manual or automatic compaction Save state or block compaction
PostCompact After compaction completes Log or inspect the generated compact summary
InstructionsLoaded When CLAUDE.md or a rule file enters context Audit which instructions were actually loaded

The lifecycle is roughly:

SessionStart
    |
UserPromptSubmit
    |
PreToolUse
    |
tool executes
    |
PostToolUse or PostToolUseFailure
    |
Claude continues or calls more tools
    |
Stop

Compaction, subagents, permissions, notifications, task tracking, worktrees, MCP elicitation, configuration changes, and other events add more interception points around that basic loop.

Hooks are configured outside the prompt

Hooks normally live in a Claude Code settings file.

Location Scope Usually committed
~/.claude/settings.json Every project for the current user No
.claude/settings.json One repository Yes
.claude/settings.local.json One repository on one machine No
Managed policy settings Organization-wide Administrator-controlled
Plugin hooks/hooks.json Wherever the plugin is enabled Bundled with the plugin
Skill or agent frontmatter While that component is active Usually yes

A project-level hook has this general shape:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-bash.py"
          }
        ]
      }
    ]
  }
}

The outer key is the event. The matcher narrows the event, such as matching only the Bash tool or either Edit or Write. The inner hooks array contains the handlers that Claude Code runs.

The most common handler is a command hook, but current Claude Code versions also support:

  • command: run a local process.
  • http: send the event payload to an HTTP endpoint.
  • mcp_tool: call a tool from an already connected MCP server.
  • prompt: ask a model for a single-turn yes-or-no decision.
  • agent: run a tool-using verification subagent.

Only the command, HTTP service, or MCP implementation can be deterministic in the strict sense. Prompt and agent hooks are useful for judgment-heavy checks, but they still depend on model behavior.

What a hook receives

For command hooks, Claude Code writes a JSON event object to standard input. The exact fields depend on the event.

A PreToolUse event for Bash looks conceptually like this:

{
  "session_id": "abc123",
  "cwd": "/home/user/project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "pytest -q",
    "description": "Run the test suite"
  },
  "tool_use_id": "toolu_01ABC123"
}

Do not construct shell commands by directly interpolating untrusted values from this object. Parse the JSON with jq, Python, Node.js, or another real parser, preserve quoting, and validate paths before using them.

PreToolUse: the enforcement primitive

PreToolUse is the most important event for guardrails because it fires before the selected tool runs.

A command hook can exit with code 2 to block the call, or it can exit successfully and print structured JSON for finer control.

For PreToolUse, the decision belongs inside hookSpecificOutput:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Production deployment commands are blocked from this project."
  }
}

The available decisions are:

Decision Effect
allow Approve the tool call without the normal permission prompt, subject to remaining permission rules and tools that always require interaction
deny Prevent the tool call from executing
ask Show the user a permission prompt
defer Pause a tool call in supported non-interactive -p workflows so another process can resume it later

Most interactive workflows use allow, deny, or ask.

Rewriting tool input

A PreToolUse hook can also return updatedInput. Claude Code then runs the tool with the replacement input rather than the original arguments.

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "A credential-like value was redacted before execution.",
    "updatedInput": {
      "command": "curl -H 'Authorization: Bearer [REDACTED]' https://api.example.test",
      "description": "Call the test API"
    }
  }
}

The important detail is that updatedInput replaces the entire tool input object. It is not a partial patch. If the original input contained description, timeout, run_in_background, or another field, preserve that field unless you intentionally want to remove it.

A practical guardrail: redact a secret instead of only blocking

Blocking a dangerous command is useful, but input rewriting enables a more nuanced pattern.

Suppose Claude prepares a Bash command containing a live-looking payment API key. A hook can replace the secret before the shell ever sees the command.

Create .claude/hooks/redact-bash-secrets.py:

#!/usr/bin/env python3
from __future__ import annotations

import json
import re
import sys
from typing import Any


SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
    (
        re.compile(r"sk_live_[A-Za-z0-9_-]{16,}"),
        "sk_live_[REDACTED]",
    ),
    (
        re.compile(
            r"(?i)(authorization:\s*bearer\s+)[A-Za-z0-9._~+/=-]{16,}"
        ),
        r"\1[REDACTED]",
    ),
)


def load_payload() -> dict[str, Any]:
    payload = json.load(sys.stdin)

    if not isinstance(payload, dict):
        raise ValueError("Hook input must be a JSON object.")

    return payload


def redact(command: str) -> str:
    result = command

    for pattern, replacement in SECRET_PATTERNS:
        result = pattern.sub(replacement, result)

    return result


def main() -> int:
    try:
        payload = load_payload()
        tool_input = payload.get("tool_input")

        if not isinstance(tool_input, dict):
            raise ValueError("Missing tool_input object.")

        command = tool_input.get("command")

        if not isinstance(command, str):
            raise ValueError("Missing Bash command.")

        redacted_command = redact(command)

        if redacted_command == command:
            # No structured output means the normal permission flow continues.
            return 0

        # updatedInput replaces the whole input object, so copy every field.
        updated_input = dict(tool_input)
        updated_input["command"] = redacted_command

        result = {
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "allow",
                "permissionDecisionReason": (
                    "Credential-like text was redacted before execution."
                ),
                "updatedInput": updated_input,
            }
        }

        json.dump(result, sys.stdout)
        return 0
    except (json.JSONDecodeError, OSError, ValueError) as error:
        # Policy hooks should fail closed.
        print(f"Secret-redaction hook failed: {error}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

Make it executable:

chmod +x .claude/hooks/redact-bash-secrets.py

Register it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/redact-bash-secrets.py"
          }
        ]
      }
    ]
  }
}

When no secret is present, the script exits successfully without returning a decision, so Claude Code continues through its normal permission flow.

When the script changes the command, it returns the complete replacement input and allows the sanitized call. Replace allow with ask when you want the user to inspect the rewritten command before it runs.

This example is intentionally small. A production secret scanner should cover the credential formats relevant to your organization, include test fixtures, and avoid logging the original secret.

Exit codes: zero, two, and everything else

Not every hook needs structured JSON. Exit codes are enough for many guardrails.

Exit code 0

Exit code 0 means the hook completed successfully.

If standard output contains valid JSON, Claude Code parses it. JSON is processed only when the hook exits with code 0.

For most events, plain-text standard output is not added to Claude's context. The important exceptions are:

  • SessionStart
  • UserPromptSubmit
  • UserPromptExpansion

For these events, plain text can become context that Claude sees.

Exit code 2

Exit code 2 means a blocking error on events that support blocking.

Standard error becomes the reason Claude or the user receives.

Examples:

  • On PreToolUse, it blocks the tool call.
  • On PermissionRequest, it denies permission.
  • On Stop, it prevents Claude from stopping.
  • On SubagentStop, it keeps the subagent working.
  • On PreCompact, it blocks compaction.

Every other non-zero code

For most events, every other non-zero status is a non-blocking hook error. Claude Code logs or displays the error and continues.

This includes exit code 1.

That behavior surprises developers because 1 conventionally means failure in Unix programs. In a Claude Code policy hook, however, 1 usually does not stop the protected action.

If a hook must enforce a rule, use 2, not 1.

if unsafe_condition; then
  echo "Blocked by project policy." >&2
  exit 2
fi

exit 0

Do not print JSON and exit 2 at the same time. Claude Code ignores JSON on a blocking exit and uses standard error as the reason.

PostToolUse: automate what should happen after success

PostToolUse runs after a tool call has already completed successfully.

This is the correct place for:

  • Formatting files after edits.
  • Running a focused linter.
  • Recording successful tool operations.
  • Updating generated metadata.
  • Adding feedback that Claude should consider before its next action.

A formatter hook might look like this:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-edited-file.sh"
          }
        ]
      }
    ]
  }
}

Create .claude/hooks/format-edited-file.sh:

#!/usr/bin/env bash
set -euo pipefail

payload="$(cat)"
file_path="$(jq -r '.tool_input.file_path // empty' <<<"$payload")"

if [[ -z "$file_path" ]]; then
  exit 0
fi

case "$file_path" in
  *.js|*.jsx|*.ts|*.tsx|*.json|*.md)
    npx prettier --write -- "$file_path"
    ;;
  *.py)
    uv run ruff format -- "$file_path"
    ;;
esac

Because PostToolUse runs after the edit, it cannot prevent or undo that edit. Even an exit code 2 is too late to stop the original tool call. It can still return feedback to Claude, but the side effect has already happened.

Use PreToolUse when the action itself must be stopped or changed.

Stop: refuse incomplete work

Stop fires when Claude wants to finish responding. A blocking result tells Claude that the turn is not complete and gives it a reason to continue.

A deterministic test gate can be implemented as a command hook:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-before-stop.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

Create .claude/hooks/verify-before-stop.sh:

#!/usr/bin/env bash
set -uo pipefail

cd "${CLAUDE_PROJECT_DIR:-.}"

# Do not run the suite when the repository has no local changes.
if [[ -z "$(git status --porcelain 2>/dev/null)" ]]; then
  exit 0
fi

if uv run pytest -q; then
  exit 0
fi

echo "The test suite is failing. Fix the failures and rerun verification before stopping." >&2
exit 2

When tests fail, Claude receives the error and continues working. When they pass, the hook exits 0 and Claude may stop.

There are two operational caveats:

  1. Stop fires whenever Claude finishes responding, not only when it believes an entire project task is complete.
  2. A full test suite on every stop can be slow.

Keep the check focused, add caching or change detection, and use a timeout appropriate for the repository. For more complex verification, a prompt or agent hook can inspect the task, but that changes the check from deterministic code into model-assisted judgment.

Preserving state across compaction

Long Claude Code sessions eventually compact their context. Compaction creates a summary and reloads instruction files, but project-specific working state can still become too vague.

Current Claude Code versions expose two useful mechanisms:

  • PostCompact receives the generated compact_summary, which is useful for logging, external synchronization, or inspection.
  • SessionStart fires again with the matcher value compact, and its plain standard output is added to Claude's context.

That makes SessionStart with compact the correct hook for restoring context that Claude must immediately use.

Register the hook:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/restore-after-compact.sh"
          }
        ]
      }
    ]
  }
}

Create .claude/hooks/restore-after-compact.sh:

#!/usr/bin/env bash
set -euo pipefail

cd "${CLAUDE_PROJECT_DIR:-.}"

echo "Post-compaction working state:"
echo
echo "Git branch:"
git branch --show-current 2>/dev/null || true

echo
echo "Changed files:"
git status --short 2>/dev/null || true

echo
echo "Diff summary:"
git diff --stat 2>/dev/null || true

if [[ -f .claude/session-state.md ]]; then
  echo
  echo "Explicit session state:"
  cat .claude/session-state.md
fi

The script's plain text becomes fresh context after compaction.

For stronger continuity, maintain .claude/session-state.md with information that Git cannot reconstruct:

  • The current objective.
  • Decisions already made.
  • Approaches that were rejected.
  • Open questions.
  • The next verification step.
  • Files that are important but unchanged.
  • External dependencies or pending responses.

Add transient state and logs to .gitignore when they may contain private data.

What PostCompact is for

PostCompact now receives the compacted conversation summary:

{
  "hook_event_name": "PostCompact",
  "trigger": "manual",
  "compact_summary": "Summary of the compacted conversation..."
}

That makes it useful for recording compaction history or updating an external memory store. It has no decision control and cannot change the summary that was already created.

Use it for side effects. Use SessionStart with the compact matcher for context re-injection.

InstructionsLoaded: audit what Claude actually received

Large repositories often contain several instruction sources:

  • A root CLAUDE.md.
  • Nested CLAUDE.md files.
  • .claude/rules/*.md.
  • Conditional rules with paths: frontmatter.
  • Included instruction files.

InstructionsLoaded fires when one of those files is loaded into context. Its payload identifies the file, scope, and load reason, including:

  • session_start
  • nested_traversal
  • path_glob_match
  • include
  • compact

This event cannot block or modify instruction loading. It is designed for observability.

An audit hook can record what was loaded:

{
  "hooks": {
    "InstructionsLoaded": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '[now | todateiso8601, .load_reason, .memory_type, .file_path] | @tsv' >> .claude/instructions-loaded.log"
          }
        ]
      }
    ]
  }
}

This is useful when Claude appears to ignore a rule. Before rewriting the rule, verify that the file was actually loaded and why.

Matchers and the if filter

A matcher usually filters an event by a single primary field.

For tool events, it matches the tool name:

{
  "matcher": "Edit|Write"
}

For SessionStart, it matches how the session started:

{
  "matcher": "startup|compact"
}

For InstructionsLoaded, it matches the load reason:

{
  "matcher": "session_start|compact"
}

Tool events can also use an if field to filter by tool name and arguments:

{
  "matcher": "Bash",
  "hooks": [
    {
      "type": "command",
      "if": "Bash(git push *)",
      "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-push.sh"
    }
  ]
}

Treat if as a convenience and performance filter, not as the only security boundary. Command parsing is best effort, and the filter can fail open when Claude Code cannot parse a Bash command.

For important policies, match the broad tool event and let the hook script parse and validate the full input. Combine hooks with Claude Code permission rules, operating-system permissions, sandboxing, protected branches, and server-side controls.

Multiple hooks do not short-circuit

When several hooks match the same event, Claude Code runs every matching handler before combining the results.

A deny from one PreToolUse hook does not stop sibling hooks from running. This matters when another sibling has a side effect such as writing a log, calling an API, or modifying a file.

For PreToolUse, the most restrictive decision wins:

deny > defer > ask > allow

Design sibling hooks so they are independently safe. Do not assume that an earlier policy hook will prevent later handlers from executing.

Asynchronous hooks cannot enforce policy

Command hooks can be marked asynchronous for slow side effects:

{
  "type": "command",
  "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/send-audit-event.sh",
  "async": true
}

This is appropriate for telemetry, notifications, or external synchronization.

It is not appropriate for a guardrail. An asynchronous hook cannot block or modify an action because Claude Code continues before the hook finishes. Decision fields and exit codes arrive too late.

Any hook that must allow, deny, ask, rewrite, or gate completion must run synchronously.

Security considerations

Hooks execute with the permissions of your operating-system user. A project hook is executable automation, not harmless configuration.

Apply the same review standard you would apply to a build script:

  • Read hook code before enabling it.
  • Be especially careful with hooks committed by another repository.
  • Parse JSON instead of using string concatenation.
  • Quote paths and pass -- before untrusted filenames.
  • Avoid writing secrets or full command payloads to logs.
  • Prefer absolute project-relative paths through ${CLAUDE_PROJECT_DIR}.
  • Give blocking hooks explicit timeouts.
  • Test allow, deny, malformed-input, and hook-failure paths.
  • Make enforcement hooks fail closed with exit code 2.
  • Keep side-effect-only hooks separate from policy hooks.
  • Do not treat prompt or agent hooks as deterministic security controls.
  • Use external controls for irreversible actions such as production deployment, branch protection, credential scope, and destructive infrastructure operations.

Hooks are powerful precisely because Claude cannot choose to skip the configured lifecycle event. That also means a compromised or careless hook can run repeatedly without Claude choosing to stop it.

A practical starter configuration

A useful first setup combines four patterns:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/redact-bash-secrets.py"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-edited-file.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-before-stop.sh",
            "timeout": 120
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/restore-after-compact.sh"
          }
        ]
      }
    ]
  }
}

This setup gives you:

  • A pre-execution guardrail.
  • Automatic formatting after edits.
  • A verification gate before completion.
  • State restoration after context compaction.

Start with one hook, test it in a disposable repository, inspect it through /hooks, and only then expand the configuration.

Wrapping up

A CLAUDE.md rule asks Claude to remember. A hook attaches code to the runtime.

Use PreToolUse when an action must be inspected, denied, escalated, or rewritten before execution. Use PostToolUse for formatting and other after-success automation. Use Stop to prevent premature completion. Use SessionStart with the compact matcher to restore operational context after compaction, and use PostCompact to observe or archive the compacted summary.

The strongest hook setups are not the largest. They encode a few high-value invariants:

  • Secrets do not reach commands.
  • Dangerous operations require explicit approval.
  • Edited files are formatted.
  • Tests must pass before work is declared complete.
  • Critical state survives compaction.

That is where hooks pay for themselves: not when everything goes as expected, but when they catch the one skipped step during a long run you were not watching.

References

  • Anthropic: Hooks reference - code[.]claude.com/docs/en/hooks
  • Anthropic: Automate actions with hooks - code[.]claude.com/docs/en/hooks-guide
  • Anthropic: Claude Code power user customization: How to configure hooks - claude[.]com/blog/how-to-configure-hooks

Post a Comment

0 Comments

Post a Comment (0)

#buttons=(Ok, Go it!) #days=(20)

This site uses cookies from Google to deliver its services and analyze traffic. Your IP address and user-agent are shared with Google along with performance and security metrics to ensure quality of service, generate usage statistics, and to detect and address abuse. More Info
Ok, Go it!