PermissionRequest hook input: include the decision reason that SDK can_use_tool already receives, so hooks can answer unapprovable safety-check prompts

Status Open
Reported on v2.1.293
Maintainer reply None cached
Activity 0 comments · opened Oct 8, 2026

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 carries
tool_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 dangerousRemoval on bash -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 a
dangerous_removal value in decision_reason_code's closed set, which today is
outside_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 on rm plus empty suggestions. It covers

the common case and fails both ways, as above.

  • Predict the check in a PreToolUse hook: 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 a reason, but it fires after the wait has already been paid.
  • Use the SDK's canUseTool instead 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

  1. 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.

  1. A PermissionRequest:Bash hook sees decision_reason_code == "dangerous_removal" and returns

{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"…print the path, then rm the literal path…"}}}.

  1. The agent gets the rewrite at once and continues. On an outside_reads_blocked ask 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-rm prompts have no auto-deny window. A PermissionRequest deny does

reach them, as measured above.

  • #99320: the inline-shell check raises dangerousRemoval on scripts with no rm; 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_code to reach

the VS Code approval card. Same data, different consumer.

  • #88698 reports PermissionRequest decisions discarded in claude --bg sessions on macOS. In our

Linux background-job session on 2.1.293 the deny was honoured, so that may be platform- or
launch-specific.

View original on GitHub ↗