Claude Code hooks
The PermissionDenied hook in Claude Code
PermissionDenied fires after the auto mode classifier refuses a tool call. It cannot undo the refusal, but it can tell Claude the call is worth retrying.
The classifier's refusals are heuristic rather than a fixed rule, which is the whole reason this event is worth having. A PreToolUse hook can express deny when a rule already knows the answer for certain; this is the one hook that gets to react after a judgment call on ambiguous input, not a black-and-white decision.
- When it fires
- When a tool call is denied by the auto mode classifier.
- Can it block?
- No. The denial has already happened by the time this runs.
- Matcher
- Yes, on
tool_name - Matcher examples
BashWrite|Edit
What it receives
The event arrives as JSON on standard input. These are the fields worth reading.
| Field | Type | What it is |
|---|---|---|
tool_name | string | The tool that was denied. |
tool_input | object | What it wanted to run. |
denial_reason | string | Why the classifier refused it. |
prompt_id | string | The turn it belonged to. |
session_id | string | Stable for the session. |
cwd | string | Working directory. |
Exit codes
The exit code is the decision. Anything the script writes to standard error on a blocking exit is what Claude gets told.
| Exit | What happens |
|---|---|
0 | Success. |
2 | Ignored on this event. |
other | Ignored on this event. |
What it can return
retry is the only thing this event decides. Setting it true tells the model it may attempt the denied call again, which turns a dead end into a second try when you know the refusal was over-cautious.
{
"hookSpecificOutput": {
"hookEventName": "PermissionDenied",
"retry": true
}
}
Letting a read-only command through after an over-cautious refusal
Classifiers are conservative by design, and a read-only command caught by one costs the session a turn it did not need to lose. Recognising the safe shapes and allowing a retry is a small correction.
In settings.json
{
"hooks": {
"PermissionDenied": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node ~/.claude/hooks/allow-retry.mjs" }
]
}
]
}
}
The script
const input = JSON.parse(require("fs").readFileSync(0, "utf8")) || {};
const cmd = (input.tool_input || {}).command || "";
const safe = /^(ls|cat|git status|git diff)\b/.test(cmd);
console.log(JSON.stringify({
hookSpecificOutput: { hookEventName: "PermissionDenied", retry: safe }
}));
process.exit(0);
Exit codes are ignored here, so the JSON on stdout is the only way this hook says anything.
The catch
This fires for classifier denials, not for a call you refused at a prompt and not for one a PreToolUse hook blocked. If your hook never seems to run, check which of the three actually stopped the call.