The annoyance
I put exit 2 on a PermissionRequest hook. I had just used that code on PreToolUse, where it blocks the tool, so I expected the same deny here. The script checks the Bash command, writes “Blocked” to stderr, and exits 2. /hooks lists the handler. I ask Claude to run rm. The hook runs. The permission dialog still appears. stderr never surfaces.
In a session that cannot show a prompt, the call follows the normal auto-deny path. That is Claude Code deciding, not my script. The hook looked finished. It changed nothing.
What Anthropic actually says
From the official hooks reference, under “Exit code 2 behavior per event”. PermissionRequest. Can block? No.
“Exit code 2 isn't honored for this event and the permission flow proceeds unchanged. Deny through the decision object instead”
The row I had memorised is the one above it. PreToolUse. Can block? Yes. “Blocks the tool call.”
Under “PermissionRequest decision control” the same page is blunt:
“A hook that exits 2 without a decision object leaves the permission flow unchanged, and its stderr is discarded. Only the decision object can grant or deny the request.”
behavior is allow or deny. message and interrupt are for deny only. Official wording: “For "deny" only: tells Claude why the permission was denied.” And: “For "deny" only: if true, stops Claude.”
An agent hook does not sneak a deny in either. Official text:
“If you configure an agent hook on this event, Claude Code skips it and the permission flow proceeds unchanged. To allow or deny from a hook, return the decision object from a command or HTTP hook.”
The hooks guide shows the same object used to allow, under “Auto-approve specific permission prompts”:
“Unlike the exit-code examples above, auto-approval requires your hook to write a JSON decision to stdout.”
Returning "behavior": "allow" answers the prompt on your behalf. Deny is that JSON with behavior set to deny. A different exit code is not a second way in.
I also see operators wiring Notification hooks for the wait, and policy hooks around the prompt. A permission_prompt notification fires only after the dialog has already been sitting there. PermissionRequest is the moment before that dialog. Exit 2 still does not deny it.
The fix
This is the script I had. It matches the protect-files shape from PreToolUse. On this event it changes nothing: stderr is discarded, and the permission flow proceeds.
#!/bin/bash
# deny-rm.sh — exit 2 does not deny a PermissionRequest
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [[ "$COMMAND" == rm* ]]; then
echo "Blocked: $COMMAND" >&2
exit 2
fi
exit 0Wire a command hook on PermissionRequest. Keep the matcher on Bash, not every tool. This belongs under the hooks key in a settings file:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/deny-rm.sh"
}
]
}
]
}
}The script that denies prints the decision object and exits 0. message is what Claude sees. interrupt: false leaves the session running after the deny.
#!/bin/bash
# deny-rm.sh — print the decision object, then exit 0
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [[ "$COMMAND" == rm* ]]; then
cat <<'EOF'
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": "Blocked by policy hook",
"interrupt": false
}
}
}
EOF
fi
exit 0Then: chmod +x .claude/hooks/deny-rm.sh
Prove it
Prove the script prints a deny, then prove Claude Code loaded that handler.
# Script must print the decision and exit 0
echo '{"tool_input":{"command":"rm -rf node_modules"}}' | .claude/hooks/deny-rm.sh; echo "exit=$?"
# stdout contains "behavior": "deny"
# exit is 0, not 2
# In-session: /hooks
# Confirm PermissionRequest, matcher Bash, type command
# Ask for a Bash rm that needs permission
# The dialog should not appear; Claude should see the deny messageIf /hooks does not list the handler, the gate is not installed. If the dialog still appears and stderr is nowhere, you are still exiting 2. The reference says that stderr is discarded.
Checklist
- Read the event. PreToolUse blocks on exit 2. PermissionRequest does not.
- Deny with a decision object:
behaviordeny,messagefor Claude,interruptonly when you want the session to stop. - Exit 0 after that JSON. Exit 2 without the object leaves the permission flow unchanged.
- Use a command, HTTP, MCP, or prompt hook. An agent hook on PermissionRequest is skipped.
- Keep the matcher on the tool you mean to gate.
Bashhere, not every prompt. - Run
/hooks, then trigger one prompt that should deny, and confirm the dialog does not appear.
Why it fails
I treated PermissionRequest as PreToolUse under another name. The exit code I trust from the file gate is documented as ignored here. The hook runs, the dialog proceeds, and the only record I wrote was thrown away. The decision object is the deny.
Same family as Exit 1 Does Not Block, where the Unix failure code is not the stop, CLAUDE.md Never Skips Permission Prompts, where a sentence in the project file never replaces the permission system, and Bypass Permissions in Project Settings Does Nothing, where the setting looks finished and is ignored. This one runs, exits the code I thought was the block, and still does not decide.
If you want to run this with other operators rather than on your own, we are doing that in the public Skool community, and that is https://www.skool.com/navaigate.
Sources
From idea to operation
Make the next AI decision concrete.
NavAIgate helps leadership teams identify high-value AI opportunities, prove them safely and turn the winners into working systems.
