What you will be able to do
- Write a PreToolUse command hook that denies a destructive shell command with a reason
- Register the same guardrail as a programmatic callback in the Claude Agent SDK
- Place hooks correctly in the permission evaluation order, including under bypassPermissions
- Distinguish a Claude Code hook from Enterprise inference hooks
1.Returning a deny decision from a command hook
A command hook gets the event as JSON and decides by what it prints. The script below is the handler from the Claude Code hooks reference, registered on PreToolUse with matcher Bash and if condition Bash(rm *). It reads tool_input.command with jq. When the command contains rm -rf, it prints a hookSpecificOutput object whose permissionDecision is "deny" and whose permissionDecisionReason gives the reason. Claude Code acts on that result, and the command never runs.
#!/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
fiLook at the else branch. Exiting 0 with no output does *not* mean 'approved'. The comment says there is no decision, so the normal permission flow applies. A guardrail hook only has to handle the cases it cares about. For everything else it stays out of the way, and the rest of the permission system carries on as it would have without the hook.
The hooks guide shows a second style with the same effect. Its protect-files.sh hook, registered on PreToolUse with matcher Edit|Write, checks tool_input.file_path against patterns such as .env, package-lock.json and .git/. On a match it writes a 'Blocked: …' message to stderr and exits with code 2.
2.The same guardrail as an Agent SDK callback
If you build your own agent on the Claude Agent SDK, you can write the guardrail as an in-process callback rather than a shell script. The callback receives the hook input (tool name, tool input, event name), the tool use ID, and a context object. It returns the same hookSpecificOutput shape. You register it in ClaudeAgentOptions under the event name with a HookMatcher, for example HookMatcher(matcher="Write|Edit", hooks=[protect_env_files]).
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": "deny",
"permissionDecisionReason": "Cannot modify .env files",
}
}
# Return empty object to allow the operation
return {}SDK applications can combine two kinds of hook. Filesystem hooks in .claude/settings.json are for sharing hooks between CLI and SDK sessions. They support all five handler types and fire in the main agent and in any subagents it spawns, but only after you load them: the SDK example sets settingSources: ["project"] for exactly that reason. Programmatic callbacks passed to query() are for application-specific logic and structured decisions. They also fire inside subagents, and their input carries agent_id and agent_type, so you can tell which agent triggered the hook. For language coverage, PreToolUse, PostToolUse and PermissionRequest exist in both the Python and TypeScript SDKs. Several other events, such as PreModelSwitch and ElicitationResult, are TypeScript-only.
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.
Sources3
3.Where hooks sit in permission evaluation
Hooks are one step in a layered permission check, and they come first. The Agent SDK permissions guide gives the order below. Each step can settle the call, and anything left undecided moves on to the next step.
| Step | What it does |
|---|---|
| 1. Hooks | Run custom code to allow, deny, or modify tool requests |
| 2. Deny rules | e.g. disallowed_tools=["Bash(rm *)"] denies matching calls in every permission mode |
| 3. Ask rules | Explicit ask rules route the call to approval |
| 4. Permission mode | bypassPermissions, acceptEdits, plan, or other modes |
| 5. Allow rules | e.g. allowed_tools=["Read", "Grep"] auto-approves those tools |
| 6. canUseTool callback | Prompts users for approval at runtime, when no earlier step resolves the call |
This ordering is what makes a hook dependable. Loosening the mode does not switch it off. Deny rules, explicit ask rules and hooks are all evaluated before the mode check, so they can still block a tool under acceptEdits or bypassPermissions. The later steps still matter, though. A hook that returns no decision passes the call to the rules, the mode, and finally to canUseTool, where a person can approve it at runtime. That is how you get 'approve before it runs, then let it execute' for sensitive tools. The permission hook event for this is PermissionRequest, which Anthropic's tips suggest using to route permission prompts to Slack for review.
Sources4
4.A different layer: Enterprise inference hooks
Don't confuse Claude Code hooks, which run on the machine or inside the SDK process, with inference hooks. Inference hooks are a beta Enterprise feature managed by Owners and Primary Owners. With them turned on, Claude sends every prompt to a server you host. That server checks the prompt against your policy and answers allow or deny, and Claude continues only after it gets the answer. The check runs inside Claude's infrastructure, not on someone's device, so it doesn't depend on anything installed on employees' machines. One setup covers Claude, Claude Code, Cowork and tool calls made through skills, plugins and connectors. Common uses are data loss prevention, transcript archival and enforcing organization policy.
The two layers complement each other. A PreToolUse hook blocks a specific tool call inside one agent. An inference hook applies organization-wide policy to content before it reaches Claude.
Sources5
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.Running the agent in bypassPermissions or acceptEdits mode skips PreToolUse hooks, because those modes auto-approve tool calls.Why is that wrong?
Hooks are evaluated before the permission mode is checked. A hook that denies a call blocks it whatever the mode is.
Covered in Where hooks sit in permission evaluation
2.When a guardrail hook exits 0 without output, the tool call is approved and skips any further permission checks.Why is that wrong?
Exiting 0 with no decision means the hook has no opinion. The call carries on through the normal permission flow of rules, mode and approval.
Practise it for real
Add a project-level PreToolUse hook that stops Claude Code from editing .env, package-lock.json or anything under .git/.
1.Create
.claude/hooks/protect-files.shfrom the hooks guide. It readstool_input.file_pathfrom stdin withjq, compares it withPROTECTED_PATTERNS=(".env" "package-lock.json" ".git/"), and on a match writes a 'Blocked: …' message to stderr and runsexit 2. Otherwise it runsexit 0.Why: The script holds the deterministic rule, so blocking doesn't depend on the model following an instruction.
You should see: A script file that exits 2 for protected paths and 0 for everything else.
2.On macOS or Linux, make it executable:
chmod +x .claude/hooks/protect-files.sh.Why: A command handler runs the script directly, so it needs execute permission.
You should see:
ls -l .claude/hooks/protect-files.shshows the execute bit.3.Register it in
.claude/settings.jsonunderhooks.PreToolUsewith"matcher": "Edit|Write"and acommandhandler pointing at"$CLAUDE_PROJECT_DIR"/.claude/hooks/protect-files.sh.Why: PreToolUse fires before the edit runs, the matcher limits the hook to file-writing tools, and
.claude/settings.jsoncan be committed so the whole team gets it.You should see: The hook is part of the project configuration and can be committed.
4.Test it: ask Claude Code to add a line to
.env, then to edit an ordinary source file.Why: Testing both branches shows the hook blocks exactly the protected paths and nothing else.
You should see: The
.envedit is blocked with the 'matches protected pattern' message. The source-file edit goes ahead as normal.
Stuck? Get a nudge
If nothing gets blocked, check that the matcher covers the tool Claude actually used (Edit vs Write) and that the script is executable.
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/hooksOfficial docs
“no decision; normal permission flow applies”
↩︎ Returning a deny decision from a command hook“no decision; normal permission flow applies”
↩︎ Exam trap 2 - 2.https://code.claude.com/docs/en/hooks-guideOfficial docs
“PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")”
↩︎ Returning a deny decision from a command hook - 3.
“Application-specific logic, structured decisions, and in-process integration. These also fire inside subagents.”
↩︎ The same guardrail as an Agent SDK callback“settingSources: ["project"], // Loads hooks from .claude/settings.json”
↩︎ The same guardrail as an Agent SDK callback - 4.
“Deny rules, explicit ask rules, and hooks are evaluated before the mode check and can still block a tool.”
↩︎ Where hooks sit in permission evaluation“canUseTool callback: prompt users for approval at runtime, when no earlier step resolves the call.”
↩︎ Where hooks sit in permission evaluation“Deny rules, explicit ask rules, and hooks are evaluated before the mode check and can still block a tool.”
↩︎ Exam trap 1 - 5.
“Inference hooks lets your compliance team inspect and enforce policy on every prompt, tool call response, and uploaded file text before it reaches Claude.”
↩︎ A different layer: Enterprise inference hooks