โ† Back to Blog
Developer ProductivityAgentsTools

Claude Code Hooks: Deterministic Guardrails for AI Coding Agents

Prompts are suggestions; hooks are rules. Learn how Claude Code hooks block dangerous commands, enforce formatting and tests after every edit, and give you a deterministic control layer around your coding agent.

By Joinloopยท27 October 2026ยท10 min read
ShareLinkedInXReddit
๐Ÿช

If you have used an AI coding agent for more than a week, you have probably written some version of this in your CLAUDE.md: "Never run rm -rf. Always run the formatter before finishing. Don't commit secrets." The agent follows these rules most of the time. "Most of the time" is the problem. A rule in a prompt is a request the model weighs against everything else in its context. Under a long session, a tight context budget, or an unusual task, that weighing sometimes comes out the wrong way.

Hooks solve this by moving the rule out of the model entirely. A hook is a shell command that Claude Code runs at a fixed point in the agent's lifecycle, for example right before a tool call or right after a file edit. The harness runs it, reads its exit code, and acts on the result. The model cannot talk its way past a hook, because the model never decides whether the hook runs.

This post covers the mental model, how to configure hooks, a working Python guardrail you can copy, a comparison of where each kind of rule belongs, and the mistakes that bite people in their first week.

The problem: instructions are probabilistic, controls should not be

Think about the two kinds of rules you want on a coding agent:

  1. Preferences: "Use named exports." "Prefer pnpm over npm." Losing one of these now and then is annoying but harmless.
  2. Invariants: "Never write to .env." "Never run git push --force on main." "Never delete a migration file." Losing one of these is an incident.

Put preferences in CLAUDE.md and let the model handle them. Put invariants in code that cannot be ignored. The distinction matters because a prompt-level rule has a failure rate, and that rate is not zero for any model you can buy today. If the cost of one failure is a leaked credential, a failure rate of 1 in 500 is too high.

Hooks give you a second layer. The prompt says what you want. The hook enforces the part you cannot afford to lose.

How hooks work

Hooks are configured in JSON settings files. The main locations are:

File Scope Committed to git?
~/.claude/settings.json Every project on your machine No
.claude/settings.json Everyone on the repo Yes
.claude/settings.local.json You, on this repo only No (gitignore it)

The common lifecycle events are:

Event Fires when Typical use
PreToolUse Before a tool runs Block dangerous commands or protected file writes
PostToolUse After a tool succeeds Format, lint, or type-check the file just edited
UserPromptSubmit When you send a prompt Inject context, or reject prompts that match a policy
Stop When the agent finishes responding Run the test suite before accepting "done"

A hook is registered under an event, optionally filtered by a matcher that names the tool (for example Bash or Edit|Write). The hook command receives a JSON payload on stdin describing the call, including the tool name and its input. What happens next depends on the exit code:

  • Exit 0: proceed. Anything the hook printed to stdout is usually shown in transcript mode, not fed back as a decision.
  • Exit 2 on a PreToolUse hook: block the call. The hook's stderr is passed back to Claude, so the agent learns why it was blocked and can change course.
  • Any other non-zero exit: this is the footgun. Most Unix tools exit 1 on failure, and in many hook events 1 does not block anything. Your guardrail silently does nothing. Always use exit 2 to block, and test that it actually blocks.

Here is a minimal configuration that runs a guard script before every shell command:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 .claude/hooks/guard_bash.py"
          }
        ]
      }
    ]
  }
}

Check the official reference before you rely on field names. The event names, matcher syntax and JSON payload shape above reflect how hooks are commonly documented, but Claude Code evolves quickly. Confirm the current schema at the official hooks docs (code.claude.com/docs/en/hooks) and run a deliberately bad command once to prove your guard blocks it.

A working guardrail: block risky shell commands

Here is a guard script that inspects every Bash call and blocks a short list of patterns that should never run unattended. It reads the payload from stdin, checks the command, and exits 2 with an explanation on stderr if it matches.

#!/usr/bin/env python3
# .claude/hooks/guard_bash.py
import json
import re
import sys

# Each rule: (regex, human-readable reason). Keep these specific.
BLOCKED = [
    (r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+(/|~|\$HOME|\.\s*$)",
     "Recursive delete of a root, home, or project directory"),
    (r"\bgit\s+push\b.*\s--force(-with-lease)?\b.*\b(main|master)\b",
     "Force-push to the default branch"),
    (r"\bgit\s+push\b.*\s(--force|-f)\b",
     "Force-push (use --force-with-lease on a feature branch if you must)"),
    (r"\b(cat|less|head|tail|cp|mv)\b.*\.env(\.\w+)?\b",
     "Reading or copying a .env file, which may contain secrets"),
    (r"\bcurl\b.*\|\s*(sh|bash)\b",
     "Piping a remote script straight into a shell"),
    (r"\bDROP\s+(TABLE|DATABASE)\b",
     "Destructive SQL statement"),
]


def main() -> int:
    try:
        payload = json.load(sys.stdin)
    except json.JSONDecodeError:
        # Fail closed: if we cannot read the request, do not guess.
        print("guard_bash: could not parse hook payload; blocking.", file=sys.stderr)
        return 2

    if payload.get("tool_name") != "Bash":
        return 0

    command = payload.get("tool_input", {}).get("command", "")

    for pattern, reason in BLOCKED:
        if re.search(pattern, command, flags=re.IGNORECASE):
            print(
                f"Blocked by project policy: {reason}.\n"
                f"Command: {command}\n"
                "If this is intentional, ask the user to run it themselves.",
                file=sys.stderr,
            )
            return 2

    return 0


if __name__ == "__main__":
    sys.exit(main())

Three design choices in this script are worth copying:

  1. It fails closed. If the payload cannot be parsed, the guard blocks instead of allowing. A guard that quietly passes when it breaks is worse than no guard, because you stop looking at it.
  2. The stderr message is written for the model. When the call is blocked, the agent reads that text and adapts. "Ask the user to run it themselves" is a useful instruction; a bare "blocked" is not.
  3. The rule list is short and specific. Regex guards are a backstop for obvious accidents, not a sandbox. A determined command can always be rewritten to slip past a pattern. Treat this as a seatbelt, not a lock.

Test it by hand before you trust it:

echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \
  | python3 .claude/hooks/guard_bash.py
echo "exit code: $?"

You should see the blocked message and exit code: 2. If you see exit code: 0, the guard is not doing its job.

A second hook: enforce formatting and types after every edit

Hooks are not only for blocking. A PostToolUse hook on Edit|Write is a good way to make sure every change the agent makes is formatted and type-checked, without relying on the agent to remember.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3 .claude/hooks/after_edit.py"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env python3
# .claude/hooks/after_edit.py
import json
import subprocess
import sys

payload = json.load(sys.stdin)
path = payload.get("tool_input", {}).get("file_path", "")

if path.endswith((".ts", ".tsx")):
    # Format, then type-check. Surface type errors back to the agent via stderr.
    subprocess.run(["npx", "prettier", "--write", path], check=False)
    result = subprocess.run(
        ["npx", "tsc", "--noEmit"], capture_output=True, text=True
    )
    if result.returncode != 0:
        print(result.stdout + result.stderr, file=sys.stderr)
        sys.exit(2)

sys.exit(0)

The tsc call is the interesting part. Running the type checker on every edit is slow on a large project, so in practice you may scope it to the changed file's package, or move the full check to a Stop hook. The principle is the same: the check runs whether or not the agent remembered to ask for it.

Where each kind of rule belongs

Not every rule should be a hook. Use this table to decide.

Rule type Example Put it in Why
Style preference "Prefer early returns" CLAUDE.md Cheap to violate, judgment-based
Mechanical formatting "Code must pass Prettier" PostToolUse hook Deterministic, automatable
Blocked action "Never force-push to main" PreToolUse hook + branch protection Must not depend on the model
Definition of done "Tests pass before finishing" Stop hook Gates the agent's final answer
Secret handling "Never read .env" PreToolUse hook + secret scanning Failure is a leak
Project knowledge "Our billing module uses the outbox pattern" CLAUDE.md or docs Context, not enforcement

The rule of thumb: if you would be upset to find a violation in a code review, it belongs in a hook. If you would merely be mildly annoyed, it belongs in the prompt.

Common pitfalls

Exit code 1 silently allows everything. This is the most common bug. Write sys.exit(2) explicitly and test the block path every time you change a hook.

Slow hooks make the agent feel broken. A PostToolUse hook that runs a full test suite on every keystroke-sized edit will make the agent crawl. Keep per-edit hooks fast (format, lint one file), and move expensive checks to Stop.

Regex guards give false confidence. rm -rf has a dozen spellings, and bash -c "$(echo cm0gLXJm | base64 -d)" is not on your list. Hooks reduce accidents. They do not sandbox an adversarial command. For real isolation, run the agent in a container or a restricted environment, and keep credentials out of its reach.

Hooks run with your permissions. A hook is a shell script. Review hooks in a shared .claude/settings.json the same way you review CI config, because they execute on every collaborator's machine.

Logging the payload leaks data. If you debug a hook by writing the full tool_input to a log file, you may be writing secrets from file contents or command arguments to disk. Log the tool name and a decision, not the full payload.

Blocking without explanation wastes turns. A block the model does not understand causes retries. Always write a stderr message that says what was blocked and what to do instead.

Next steps

  1. Start with one PreToolUse guard on Bash covering the three or four commands your team has actually been burned by. Do not try to write a complete policy on day one.
  2. Add a PostToolUse formatter for the languages you edit most. It is the cheapest win in the whole setup.
  3. Test every block path by piping a fake payload into the script, as shown above, and confirm the exit code.
  4. Move your CLAUDE.md "NEVER" lines into hooks where a violation would hurt, and leave the preferences in the prompt.
  5. Keep hooks in version control in .claude/hooks/, with a short README on what each one enforces and why.

Hooks are a small feature with an outsized effect. They turn "the agent usually does the right thing" into "the agent cannot do this particular wrong thing," which is the property you actually need before you let an AI agent touch a real repository.

If you want to go deeper on building and operating AI agents, including tool design, evals, and production guardrails, take a look at the Joinloop engineering cohort. We work through these patterns hands-on, on real codebases.

ShareLinkedInXReddit

Want to build this yourself?

Join a live cohort and go from reading about AI to shipping it, with practitioners who do it daily.