Claude Certification Program · v1.0 · Effective July 2026 · All four tracks open

Home › Study guides › CCDV-F › Domain 7 › Lesson 7.3

CCDV-F · Domain 7 · 8.1% of the exam · Lesson 7.3 · 21 min read

Hooks as safety controls: stopping destructive actions before they run

How PreToolUse hooks block or hold destructive commands for approval in Claude Code and the Agent SDK, what PostToolUse can check, and where hooks stop.

Written against skill 7.3 of the official CCDV-F exam guide (Version 1.0, effective July 2026). An independent resource, not affiliated with Anthropic; the practice questions are written from scratch.

7.3.1 Why a rule in the prompt cannot stop rm -rf

Picture the DevOps team at Harbourlight, a company that makes warehouse software. Radu, who leads it, lets developers use Claude Code, Anthropic's coding agent for the terminal, on the shared build servers. His colleague Thandiwe has built a nightly release agent with the Claude Agent SDK, the library that runs the same agent inside your own program. It clears old build output, bumps versions, tags releases and pushes them. Both can run shell commands. That is the point, and also the risk: one wrong command on a build server can wipe a week of artefacts, rewrite the shared release branch or restart a production service.

Radu's first safeguard was a set of rules: never run rm -rf, never force push, never touch production without asking. He wrote them into CLAUDE.md, the instruction file Claude Code reads at the start of every session, and into the release agent's system prompt, its standing instructions. For weeks the rules held. Then a session asked to "clear out the old builds" ran rm -rf builds/* and took the current release's artefacts with it. A week later the release agent hit a rebase conflict and settled it with git push --force. The rules were in context both times. Both times, the model's reading of the task outweighed them.

That is what an instruction is. Text in a prompt or in CLAUDE.md is context the model weighs, and it usually follows it, but whether it does on a given call is a probability. Think of a server-room door with a sign saying "authorised staff only" and a badge reader beside it. The sign works on people who read it and agree; the badge reader works on everyone, every time. Radu needs the badge reader.

That badge reader is a hook. Claude never runs a command itself. It asks for a tool call, such as the Bash tool with a command. The program around it, called the harness, runs the call and hands back the result; Claude Code and the Agent SDK are both harnesses. A hook is your own code that the harness runs at a fixed point in that cycle, whatever the model decided. A PreToolUse hook runs after Claude has asked for a tool call and before the call executes. It sees the exact command and can block it, hold it for a person's approval, or let it through.

Asking the model versus checking the call

Instruction in the prompt

"Never run rm -rf"in CLAUDE.md or the system prompt
The model weighs itagainst the task in front of it
Usually followednot always

PreToolUse hook

Claude requests rm -rf builds/*
Your code checks the commandbefore it runs
Blocked, every time
An instruction is one more thing the model weighs. A PreToolUse hook is code that inspects every matching call before it runs, whatever the model decided.

7.3.2 Where a hook sits, and what it can decide

Every guardrail design turns on one question: at what moment can your code still stop an action? There is exactly one, the gap between Claude asking for a tool and the tool running. Claude writes a tool call, such as Bash with git push --force origin release. The harness first runs your PreToolUse hooks. Next it checks its permission rules, the allow, ask and deny lists in settings, and its permission mode, which sets how much runs without asking, from prompting for most actions to bypassPermissions, which skips the prompts. Then it runs the tool. After a successful call it runs any PostToolUse hooks on the result.

The one moment a hook can still say no

Claude requests a callBash: git push --force
PreToolUse hooksyour code: deny, ask, allow or pass
Permission rules and modedeny, ask and allow rules
The tool runs
PostToolUse hooksread the result, cannot undo it
PreToolUse hooks run before the permission rules and before the tool. PostToolUse hooks run after the tool, when its effects already exist.

Because hooks run first, a hook's "no" holds everywhere. A hook that denies a call blocks it even in bypassPermissions mode, so nobody switches the policy off by changing modes. The reverse does not hold: a hook's "yes" never overrides a deny or ask rule in settings. Hooks can tighten permissions but never loosen them. When several hooks answer the same call, the most restrictive answer wins, in the order deny, defer, ask, allow.

What the hook returns What happens to the call In Radu's policy
deny (or exit code 2 from a command hook) Cancelled; the reason goes to Claude Recursive rm, force pushes
ask Waits for a person: the prompt in Claude Code, your approval callback in the SDK Anything naming production
No decision ({}, or exit 0 with no JSON) The normal permission rules and mode decide Every other command
allow Skips the permission prompt; deny and ask rules still apply Not used for safety
defer Ends a non-interactive run so your application can resume the call later Not used here; suits approvals that take hours

Memorise deny, ask and no decision; recognise allow and defer. Hooks do quieter jobs too, such as logging every call, but here we care about the ones that say no.

7.3.3 A guard for Claude Code: matcher, script, exit code

How does Radu turn that policy into something Claude Code runs? A Claude Code hook lives in a settings file, in three layers: the event (PreToolUse), a matcher that filters by tool name, and a handler, usually a shell command. Radu commits it to the project's .claude/settings.json. On the build servers, his team also installs it in managed settings, the file an administrator places on the machine, whose hooks project and user settings cannot switch off.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.py"
          }
        ]
      }
    ]
  }
}

The matcher is an exact tool name such as Bash, a list such as Edit|Write, or a regular expression such as mcp__deploy__.*. That last one catches every tool from one MCP (Model Context Protocol) server, because such tools are named mcp__<server>__<tool>. It is case-sensitive, and a hook with no matcher fires for every tool call. The matcher only sees the tool name, so the script checks the arguments.

Claude Code sends the call to the script as JSON on standard input, with the shell command in tool_input.command, and reads the answer from the exit code. Look at the two exits. Exit 2 blocks the call and sends the text written to standard error (stderr) to Claude as the reason. Exit 0 means no objection, unless the script printed a JSON decision, as it does for production.

#!/usr/bin/env python3
import json, re, sys

event = json.load(sys.stdin)                          # Claude Code sends the call as JSON
command = event["tool_input"].get("command", "")

NEVER = [r"\brm\s+(-\w+\s+)*-\w*[rR]",               # rm -rf, rm -fr, rm -r -f
         r"\bgit\b.*\bpush\b.*(--force|\s-f\b)"]      # force pushes
if any(re.search(p, command) for p in NEVER):
    print(f"Blocked by build-server policy: {command}", file=sys.stderr)
    sys.exit(2)                                       # exit 2 = BLOCK; stderr goes to Claude

if re.search(r"\bprod", command):                     # anything naming production
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "ask",                  # a person must approve
        "permissionDecisionReason": "This command touches production"}}))
sys.exit(0)                                           # exit 0 = no objection

Now the trap that catches most people. Only exit code 2 blocks on its own. Exit 1, the usual Unix failure code, is a non-blocking error when the script prints no JSON decision: Claude Code shows a hook error notice and the call PROCEEDS as if the hook had never objected. So does a script that cannot start because its path has a typo, and a command hook that times out. A guard that breaks in any of these ways fails open: when it breaks, everything gets through. Test it by piping sample JSON into it before you trust it.

A deny rule in settings, such as Bash(git push --force *), is simpler for one fixed pattern. A hook earns its place when the decision needs logic: several patterns, the target environment, a call to your own policy service, or a person's approval.

7.3.4 The same guard in the Agent SDK, with an approval step

Thandiwe's release agent has no terminal, so nobody is there to press "approve". How does an unattended agent get a person's yes before it touches production? In the Agent SDK a hook is a Python or TypeScript function that you pass in the agent's options, registered by event and matcher exactly as in settings. It receives the same fields, such as tool_name and tool_input, and returns the same decision object; an empty dict means no decision.

Look at the except branch, which makes the guard fail CLOSED (a broken check blocks the call instead of passing it), and at can_use_tool, where every "ask" lands. check_command stands for your own rules, such as the patterns above.

from claude_agent_sdk import ClaudeAgentOptions, HookMatcher

async def build_server_guard(input_data, tool_use_id, context):
    command = input_data["tool_input"].get("command", "")
    try:
        verdict, reason = check_command(command)      # your rules: "deny", "ask" or "ok"
    except Exception:
        verdict, reason = "deny", "Policy check failed, so the call is blocked"
    if verdict == "ok":
        return {}                                      # no decision: normal permissions apply
    return {"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": verdict,                 # "deny" blocks; "ask" needs approval
        "permissionDecisionReason": reason,
    }}

options = ClaudeAgentOptions(
    hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[build_server_guard])]},
    can_use_tool=ask_on_call_engineer,                 # where every "ask" lands
)

When the hook answers "ask", the SDK hands the call to your can_use_tool callback and passes your reason along as decision_reason. That callback is your approval step: Thandiwe's posts the command to the on-call engineer's chat and waits for a yes or no. Tool calls made by subagents, helper agents the main agent starts, fire the same hooks with agent_id in the input, so one guard covers them all. Timeouts differ too. An SDK callback that times out on PreToolUse blocks the call, where a Claude Code command hook that times out lets it through.

The idea is not tied to Anthropic's harnesses. Wherever tools execute, put the check at that point, on the concrete call, and make it fail closed.

Harness Where the check lives How it says no or asks
Claude Code A PreToolUse command hook in settings Exit 2, or a JSON permissionDecision
Agent SDK A callback in the hooks option Returns permissionDecision; "ask" goes to can_use_tool
Your own Messages API loop Your dispatcher, before it runs each requested tool Skips the call and returns an error tool_result
An MCP server In front of each state-changing handler Refuses the call and returns an error

7.3.5 PostToolUse: checking what already ran

It is tempting to build the guard on PostToolUse, because that is where the result is. Resist it for anything destructive. PostToolUse fires after a tool call has succeeded (a failed call fires PostToolUseFailure instead), so by then the files are gone and the push has landed. The hook cannot undo anything. What it can do is read the result and react: put a reason in front of Claude, stop the run, or alert a person.

That makes it the right event for checking results. Thandiwe's agent deploys to staging every night. A PostToolUse hook on Bash reads each deploy command's output in tool_response. If the output names any environment other than staging, the hook pages the on-call engineer and returns continue set to false (continue_ in Python). That ends the run before the agent takes another step. The softer answer is decision: "block" with a reason. Despite its name, on PostToolUse it blocks nothing: it puts your reason next to the output, and Claude decides what to do next.

Before the call and after it

PreToolUse before the call

Sees the requested command
Can deny, ask or allow
Prevents the damage

PostToolUse after the call

Sees the command and its output
Can warn Claude, stop the run, alert a person
Cannot undo anything
Only the hook that runs before the call can stop it. The hook that runs after can check the result and raise the alarm, but the action has already happened.

One detail decides how much a result check covers. Claude can change files through Bash as well as through Edit and Write, so a hook that matches only the file tools never sees what a shell command wrote.

7.3.6 What a hook cannot see

A hook is deterministic, but it is not all-seeing. It reads the text of one tool call and judges only what that text reveals. Months ago Claude wrote Harbourlight a Makefile with a clean target that runs rm -rf on the artefact cache. Today a session runs make clean. Radu's guard sees make clean, which matches nothing, and the cache is gone.

What the guard sees What actually happens Why a text check misses it
make clean A Makefile target runs rm -rf The destructive command is inside a file
find build -delete Files are deleted without rm A different command with the same effect
python tidy.py A script deletes a folder The deletion is in code, not in the shell command
kubectl apply -f app.yaml A deploy lands in production The target is in the file, not in the command

A text check catches the forms of an action it was written for, not every way to cause the same effect. Adding patterns is an arms race you lose. The durable fix is layers that do not depend on the command text. Think of a build user that cannot write outside its workspace, credentials that cannot reach production, and a Git server that refuses force pushes to protected branches whatever the client sends. The hook stays, as the layer that explains the policy to Claude and asks a person at the right moment.

Three more limits. Command hooks run with your full user permissions, so review them like any other code. In non-interactive runs (claude -p) and SDK runs, the hooks committed in a repository run without the workspace trust prompt. Hooks only see tool calls: a file referenced with @ in a prompt reaches Claude without one. And Claude Code's prompt-based hooks, where a model judges the call, suit judgement calls but give up the determinism that makes a guard a guard.

7.3.7 The exam traps

Expect a scenario in which an agent or a Claude Code setup can do damage, and a question about where the control belongs. The wrong answers put the rule somewhere the model can overlook it, or somewhere too late to matter.

  • ✗ Writing "never run rm -rf" in CLAUDE.md, the system prompt or any other instructions and calling it a control. ✓ Enforce it with a PreToolUse hook. An instruction is context the model weighs; a hook runs on every matching call.
  • ✗ Guarding destructive actions with a PostToolUse hook, an audit log or a check of the final answer. ✓ Check before execution. After the call, you can only detect the damage.
  • ✗ Blocking with exit code 1, or trusting a guard nobody has tested. ✓ Exit 2 or return a JSON deny, test with sample input, and make SDK callbacks fail closed.
  • ✗ Denying every call that touches production. ✓ Deny what is never acceptable and return "ask" for what needs a person, so approved work still gets done.
  • ✗ Matching bash or delete_invoice when the tool is Bash or mcp__billing__delete_invoice. ✓ Use exact, case-sensitive tool names. A matcher that matches nothing is a guard that never runs.
  • ✗ Growing the pattern list forever and calling the hook the whole defence. ✓ Keep the hook as one layer and limit what the process itself can do.

Four places to put the rule, one that holds

A firmer CLAUDE.md rulecontext the model weighs
A larger modelfollows rules more often, not always
A PostToolUse alertthe files are already gone
A check of the final answerthe command already ran
A PreToolUse hook on the calldeny, ask or pass, every time
Each wrong answer either asks the model or looks after the fact. Only a check on the concrete call, before it runs, stops the damage every time.

7.3.8 Put it together: guard a build server and break the guard

You now have every piece: where a hook sits, what it can decide, its Claude Code and SDK forms, what PostToolUse adds, and where hooks stop. The way to trust a guard is to watch it fail in each way it can.

Hooks are one control among several. Guardrails and safe deployment (7.2) layers checks around the model and applies least privilege, so a hook is never all that stands between Claude and production. Identity, secrets and key management (7.4) gives each environment and workload its own narrow credentials, so a command that slips through still cannot reach production.

Key takeaways

  • ✓ An instruction in a prompt or CLAUDE.md is context the model weighs; a hook is code the harness runs at a fixed point every time, which makes a safety rule deterministic.
  • ✓ PreToolUse runs after Claude requests a call and before it executes, ahead of permission rules and modes; a deny holds in every mode, and the most restrictive answer wins.
  • ✓ In Claude Code a hook is an event, a matcher and a handler in settings; matchers are case-sensitive tool names, and only exit code 2 or a JSON deny blocks.
  • ✓ In the Agent SDK a callback returns the same permissionDecision, "ask" reaches your can_use_tool approval step, and the guard should fail closed.
  • ✓ PostToolUse runs after the call, so it checks results and raises alarms but never prevents the damage.
  • ✓ A hook sees only the text of one call, so it is one layer beside least privilege, sandboxing and server-side protections.

Check your understanding

4 questions written for this lesson, then one from the CCDV-F question bank on the same topic. Every answer option is explained, including the ones you did not pick. Nothing is stored.

12 CCDV-F questions on Domain 7, free

Every question in the bank is tagged to a domain, so you can drill 12 questions on Security and Safety alone, or sit the full 53-question timed simulator.

Open the CCDV-F question bank → Back to Domain 7 →

The question bank is free. It asks for an account only because the quiz engine has to store answers to score them and show which domains are weak. The questions on this page need nothing.

Sources