What you will be able to do
- Read the tool name and arguments a PreToolUse hook receives
- Block a tool call with exit code 2 and know what exit 0 and other codes do
- Return a structured deny, ask or allow decision, and know the limits of allow
- Write the same guardrail as an Agent SDK callback
1.What a PreToolUse hook receives
A PreToolUse hook runs before a tool call executes, and it is the hook event that can cancel the call. That is why destructive-action guardrails live there. Each time it fires, the handler receives the call as JSON. Shared fields such as session_id, cwd and hook_event_name come first, then tool_name, then tool_input, which holds the arguments Claude passed. For Bash the shell command is in tool_input.command. For file tools the target path is in tool_input.file_path.
{
"session_id": "abc123",
"cwd": "/Users/sarah/myproject",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}A guardrail judges these arguments, not the model's stated intent. A script that pulls out tool_input.command with jq and greps it for drop table will catch that statement however the conversation got there, whether the user asked for it or instructions hidden in a fetched page did.
Checkpoint 1 of 5· Check yourself
A hook must stop any Bash call containing git push --force. Which field of the hook input holds the text to inspect?
tool_name would only tell you the tool is Bash. The shell command itself is in the command field of tool_input.
“tool_input: the arguments Claude passed to the tool. For Bash, its command field holds the shell command.”Source: code.claude.com
2.Blocking with exit codes
The simplest guardrail answers with its exit code alone. Exit 2 blocks the call, and anything the script writes to stderr goes back to Claude as feedback, so Claude can change course rather than retry the same command blindly. Exit 0 means no objection, and the call carries on to the normal permission checks.
Other exit codes are where guardrails quietly fail. With any other code, Claude Code looks at stdout. If stdout holds JSON that passes schema validation, the JSON decides the outcome and the exit code is ignored. If stdout is plain text or empty, the call goes ahead and the transcript shows a non-blocking hook error. Suppose a script crashes with exit 1 when jq is missing. It has blocked nothing. A guardrail that must hold should exit 2 deliberately when it detects a problem, not rely on crashing.
| Exit code | Outcome for the tool call |
|---|---|
| 0 | No objection; the normal permission flow applies |
| 2 | Blocked; stderr becomes Claude's feedback |
| Other, with valid JSON on stdout | Exit code ignored; the JSON alone decides |
| Other, with plain text or empty stdout | Proceeds as a non-blocking error |
Checkpoint 2 of 5· Match them up
Match each PreToolUse hook result to what happens to the tool call
Tap a term, then the definition that fits it.
Only exit 2 blocks by itself. With other non-zero codes, stdout decides: valid JSON is acted on, and anything else lets the call proceed.
“Exit 2: Claude Code blocks the action. Write a reason to stderr.”Source: code.claude.com
Sources1
3.Structured decisions: deny, ask and allow
An exit code can say only "block" or "no objection". JSON output can say more. A PreToolUse hook prints a hookSpecificOutput object whose permissionDecision takes one of three values. deny cancels the call and sends permissionDecisionReason to Claude. ask shows the usual permission prompt to the user. allow skips the interactive prompt.
#!/bin/bash
# .claude/hooks/block-rm.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive command blocked by hook"
}
}'
else
exit 0 # no decision; normal permission flow applies
fiask fills a gap the exit code leaves. Take an operations team that wants every database-write MCP tool call to wait for a human. A PreToolUse hook with a matcher such as mcp__db__.* can return ask for write calls. The user sees the prompt, and once they approve, the tool runs normally. Blocking with deny would rule the action out entirely, which is not what the team wanted.
allow is weaker than it looks. It removes the interactive prompt and nothing else. Deny and ask rules still apply, including enterprise-managed deny lists. An rm or rmdir that targets a critical path cannot be approved by any allow rule or hook allow. The asymmetry runs the other way for blocking: hooks are evaluated before the permission mode is checked, so a hook's deny still stops a tool when the session runs in bypassPermissions.
Checkpoint 3 of 5· Check yourself
Project settings carry a deny rule for Bash(curl *). A PreToolUse hook returns permissionDecision: "allow" for a curl command. What happens?
A hook's allow only skips the interactive prompt. Deny and ask rules, including managed deny lists, still apply.
“skip the interactive permission prompt. Deny and ask rules, including enterprise managed deny lists, still apply”Source: code.claude.com
Checkpoint 4 of 5· Exam question
A team configures a PreToolUse hook that returns exit code 0 with the following JSON on stdout: `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Writing to /etc is not allowed"}}`. What happens to the pending tool call?
Correct answer: A — The tool call is blocked, and the reason is surfaced to Claude so it can adjust its next step
- A. Correct. With exit code 0, Claude Code parses the JSON; permissionDecision: deny blocks the call, and permissionDecisionReason is passed back so Claude understands why.
- B. Incorrect. Exit code 0 does not force approval; it tells Claude Code to parse the JSON output, which in this case specifies deny.
- C. Incorrect. A returned permissionDecision is a definitive programmatic decision, not a trigger for an interactive prompt.
- D. Incorrect. permissionDecision in hookSpecificOutput is honored on exit code 0; exit code 2 is a separate blocking mechanism used when no JSON is returned.
4.The same guardrail in the Agent SDK
In the Agent SDK, hooks are callback functions passed in the agent's options instead of entries in a settings file. The hooks option maps an event name such as PreToolUse to a list of matchers. Each matcher has a pattern and the callbacks to run, and the pattern follows the same rules as matchers in settings files. MCP tools keep the mcp__<server>__<action> naming.
options = ClaudeAgentOptions(
hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[my_callback])]}
)A callback answers by returning a value, not by exiting with a code. It receives the input data (tool_name, tool_input and the shared fields), a tool use ID that ties a PreToolUse event to the matching PostToolUse, and a context argument. To refuse a call it returns the same hookSpecificOutput structure a command hook prints. To let the call proceed it returns an empty object. A slow callback fails closed: one that exceeds its timeout blocks the tool call. Language support also differs. PreToolUse and PostToolUse work in both Python and TypeScript, but events such as SessionStart are TypeScript-only.
Top-level fields such as systemMessage, which shows a message to the user, and continue are accepted on every event. Some events discard them or deliver them somewhere else. If a guardrail's explanation never reaches your UI, check that event's section on the hooks page before you go hunting for a bug in the callback.
Checkpoint 5 of 5· Fill the gap
Which value completes this callback so that it blocks writes to .env files?
# Define a hook callback that receives tool call details
async def protect_env_files(input_data, tool_use_id, context):
# Extract the file path from the tool's input arguments
file_path = input_data["tool_input"].get("file_path", "")
file_name = file_path.split("/")[-1]
# Block the operation if targeting a .env file
if file_name == ".env":
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": " ? ",
"permissionDecisionReason": "Cannot modify .env files",
}
}
# Return empty object to allow the operation
return {}The SDK callback uses the same permissionDecision values as Claude Code hooks. deny cancels the call and sends the reason to Claude.
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.A PreToolUse hook that exits 0 approves the tool call.Why is that wrong?
Exit 0 only means the hook has no objection. The normal permission flow still decides, and it may still prompt the user.
Covered in Blocking with exit codes
2.A hook returning permissionDecision allow can approve anything, including rm on a critical path.Why is that wrong?
Allow only skips the prompt. Deny and ask rules still apply, and critical-path rm and rmdir removals cannot be approved by a hook's allow.
Covered in Structured decisions: deny, ask and allow
Practise it for real
Install a project PreToolUse hook that denies rm -rf and watch it block a real call.
1.Save the block-rm.sh script from this page as
.claude/hooks/block-rm.shin a scratch project.Why: Inside the project, the script can be committed alongside the settings that call it.
You should see: The file exists and reads
tool_input.commandwith jq.2.Run
chmod +x .claude/hooks/block-rm.sh.Why: A command handler has to be executable on macOS and Linux.
You should see:
ls -lshows the execute bit set.3.In
.claude/settings.json, register the script underPreToolUsewith matcherBashandifset toBash(rm *).Why: The matcher limits the hook to Bash, and the if condition limits it further to rm commands.
You should see: Claude Code loads the hook at the start of the next session.
4.Ask Claude to delete a scratch directory with
rm -rf.Why: The call matches both filters, so the handler runs and returns deny.
You should see: The call is cancelled and Claude receives "Destructive command blocked by hook".
5.Ask Claude to run
ls.Why: The matcher passes it, but the if condition does not, so the handler never runs.
You should see: The command goes through the normal permission flow.
Stuck? Get a nudge
If rm still runs, check whether the script crashed. Exit codes other than 2, with no valid JSON on stdout, let the call proceed.
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.https://code.claude.com/docs/en/hooks-guideOfficial docs
“tool_input: the arguments Claude passed to the tool. For Bash, its command field holds the shell command.”
↩︎ What a PreToolUse hook receives“Claude Code ignores the exit code, the JSON alone decides the outcome, and the hook isn’t reported as an error.”
↩︎ Blocking with exit codes“Stdout that Claude Code treats as plain text, or empty stdout: the action proceeds as a non-blocking error.”
↩︎ Blocking with exit codes“stderr becomes Claude's feedback”
↩︎ Blocking with exit codes“cancel the tool call and send the reason to Claude”
↩︎ Structured decisions: deny, ask and allow“show the permission prompt to the user as normal”
↩︎ Structured decisions: deny, ask and allow“For a PreToolUse hook this doesn’t approve the tool call: the normal permission flow still applies.”
↩︎ Exam trap 1“For a PreToolUse hook this doesn’t approve the tool call: the normal permission flow still applies.”
↩︎ Prediction“Exit 2: Claude Code blocks the action. Write a reason to stderr.”
↩︎ Checkpoint“skip the interactive permission prompt. Deny and ask rules, including enterprise managed deny lists, still apply”
↩︎ Checkpoint - 2.https://code.claude.com/docs/en/hooksOfficial docs
“Before a tool call executes. Can block it”
↩︎ What a PreToolUse hook receives“An Agent SDK callback hook that exceeds its timeout blocks the tool call.”
↩︎ The same guardrail in the Agent SDK - 3.
“Deny rules, explicit ask rules, and hooks are evaluated before the mode check and can still block a tool.”
↩︎ Structured decisions: deny, ask and allow - 4.https://code.claude.com/docs/en/agent-sdk/hooksOfficial docs
“Return empty object to allow the operation”
↩︎ The same guardrail in the Agent SDK“Some events discard them or deliver them elsewhere.”
↩︎ The same guardrail in the Agent SDK“correlates PreToolUse and PostToolUse events for the same tool call”
↩︎ The same guardrail in the Agent SDK
Also cited
- https://code.claude.com/docs/en/permission-modesOfficial docs
“rm and rmdir removals targeting a critical path, which no allow rule or PreToolUse hook”
↩︎ Exam trap 2