jared hebb ~ %

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
Bash Write|Edit

What it receives

The event arrives as JSON on standard input. These are the fields worth reading.

FieldTypeWhat it is
tool_namestringThe tool that was denied.
tool_inputobjectWhat it wanted to run.
denial_reasonstringWhy the classifier refused it.
prompt_idstringThe turn it belonged to.
session_idstringStable for the session.
cwdstringWorking 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.

ExitWhat happens
0Success.
2Ignored on this event.
otherIgnored 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.