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
PreToolUse hook
rm -rf builds/*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
Bash: git push --forceBecause 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
PostToolUse after the call
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 aPreToolUsehook. An instruction is context the model weighs; a hook runs on every matching call. - ✗ Guarding destructive actions with a
PostToolUsehook, 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
bashordelete_invoicewhen the tool isBashormcp__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
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.
- ✓
PreToolUseruns 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 yourcan_use_toolapproval step, and the guard should fail closed. - ✓
PostToolUseruns 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.