PermissionRequest hook input: include the decision reason that SDK can_use_tool already receives, so hooks can answer unapprovable safety-check prompts
Preflight Checklist
- [x] I have searched existing requests and this feature hasn't been requested yet
- [x] This is a single feature request (not multiple features)
Problem Statement
A PermissionRequest hook is told what is being asked about but not why. Its input carriestool_name, tool_input, permission_suggestions and the common fields (session_id, cwd,permission_mode, …), plus mcp_server for MCP tools. It does not carry the ask's decision reason.
The SDK already gets that reason. The can_use_tool control request carries decision_reason,decision_reason_type, classifier_approvable and a closed-set decision_reason_code, all built from
the same decisionReason the dialog shows. A hook answering the same ask gets none of them. (Checked
in the 2.1.293 and 2.1.294 bundles: the PermissionRequest input is{...common, hook_event_name, tool_name, tool_input, permission_suggestions, ...mcp_server}.)
That gap matters most for the asks bypassPermissions cannot skip. The dangerous-rm safety check
(decisionReason.type: "safetyCheck", circuitBreaker: "dangerousRemoval",classifierApprovable: false, suggestions: []) asks even in bypass mode, and no setting can approve
it. Unattended sessions pay for it:
- In a main agent, each of the first three refusals in a session waits out a 120 s auto-deny dialog
(after the third, Claude Code denies without asking).
- In a subagent there is no auto-deny window at all, and the run stalls indefinitely (#99899).
A PermissionRequest hook is the natural fix. It runs only after Claude Code has decided to ask, and
it can answer at once with a deny whose message tells the agent how to rewrite the command (a literal
path, "${VAR:?}", a script file instead of bash -c). But without the reason the hook has to guess
from the command text, for example "bypass mode, empty suggestions, and an rm word". That guess
fails in both directions:
- False positive: another bypass-immune check (
outsideReadsBlocked,restrictedMode,
isolatePeerMachines, …) or a permissions.ask rule fires on a command that happens to contain
rm, and the hook gives rm-rewrite advice for the wrong problem.
- False negative: the inline-shell branch raises
dangerousRemovalonbash -c $'…'scripts that
contain no rm at all (#99320). No word match can see that ask, so it falls through to the dialog.
Proposed Solution
Mirror the can_use_tool reason fields into PermissionRequest hook input, read-only:
{
"hook_event_name": "PermissionRequest",
"tool_name": "Bash",
"tool_input": { "command": "…" },
"permission_suggestions": [],
"decision_reason": "…the text the dialog shows…",
"decision_reason_type": "safetyCheck",
"decision_reason_code": "dangerous_removal",
"classifier_approvable": false
}
All four fields already exist on can_use_tool, with the same meaning. The one addition is adangerous_removal value in decision_reason_code's closed set, which today isoutside_reads_blocked, memory_paused and classifier_transcript_too_long. decision_reason_type
and classifier_approvable alone cannot tell dangerous rm from other manual-only safety checks (the
schema's own description names "Windows path bypass, dangerous rm" as both classifier_approvable:).
false
Nothing about how a hook's decision is applied would change. A hook could answer exactly the asks it
understands and stay silent on the rest.
Alternative Solutions
- Guess from
tool_input(what we do today): a word match onrmplus empty suggestions. It covers
the common case and fails both ways, as above.
- Predict the check in a
PreToolUsehook: not feasible. The derived-variable and inline-shell
branches depend on Claude Code's own parser (an inline-shell refusal reproduces on a command with no
-c at all), and server-side flags change what is checked between releases.
- The env switches (
CLAUDE_CODE_DISABLE_INLINE_SHELL_RM_PROMPT,
CLAUDE_CODE_DISABLE_SUBSTITUTION_RM_PROMPT): they remove the protection instead of answering it.
PermissionDenied: it carries areason, but it fires after the wait has already been paid.- Use the SDK's
canUseToolinstead of a hook: only available to SDK hosts, not to CLI or
background sessions configured through settings.
Priority
Medium - Would be very helpful
Feature Category
Configuration and settings
Use Case Example
- An unattended bypass-mode session runs
B=/tmp/d; S=$(cat f); F="$B/${S##*/}.class"; rm -f "$F". The dangerous-rm check asks, because
$F is derived from a substitution.
- A
PermissionRequest:Bashhook seesdecision_reason_code == "dangerous_removal"and returns
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"…print the path, then rm the literal path…"}}}.
- The agent gets the rewrite at once and continues. On an
outside_reads_blockedask the same hook
stays silent, and the dialog reaches a human as intended.
Measured with today's guess-based gate (Claude Code 2.1.293, Linux/WSL2, a background-job session):
- Main agent: the hook's deny arrived in 88 ms. The same refusal without the hook waited 120.3 s.
- Subagent: the deny arrived in 63 ms, where #99899 reports an indefinite wait.
The reason fields would let the hook keep those wins without guessing.
Additional Context
- #99899: subagent dangerous-
rmprompts have no auto-deny window. APermissionRequestdeny does
reach them, as measured above.
- #99320: the inline-shell check raises
dangerousRemovalon scripts with norm; the false-negative
case above.
- #93392: the rm-on-variable dialog cannot be disabled or detected from outside. The reason fields are
the detection half.
- #91811 asks for the same
decision_reason/decision_reason_type/decision_reason_codeto reach
the VS Code approval card. Same data, different consumer.
- #88698 reports
PermissionRequestdecisions discarded inclaude --bgsessions on macOS. In our
Linux background-job session on 2.1.293 the deny was honoured, so that may be platform- or
launch-specific.