PreToolUse hook denials discard `decisionReason` at transcript-write time — the denying hook is unidentifiable after the fact
The gap
When a PreToolUse hook denies a tool call, Claude Code builds the denial with the hook's identity in hand, then persists only a coarse category. The identity is never written anywhere.
From the shipped client (2.1.220, linux, Bun single-file executable), the deny is constructed as:
decisionReason: { type:"hook", hookName:`PreToolUse:${t.name}`, hookSource: p.hookSource, reason: y }
The transcript record that results carries none of it:
"toolUseResult": "Error: Hook PreToolUse:Agent denied this tool",
"toolDenialKind": "permission-rule"
hookName, hookSource, and reason are all computed and then dropped. Verified at the artifact: across three sessions of real denials in ~/.claude/projects/**/*.jsonl, the only denial-related key present on any deny record is toolDenialKind.
Explicitly not a toolDenialKind complaint
permission-rule covering hook denials is documented behavior, not a mislabel — the client's own reference text defines it as "(deny rule / permission mode / hook)". MRd() returning it for a hook deny is correct. This issue asks only that the discarded decisionReason be persisted alongside it.
Repro
Any PreToolUse hook that denies:
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny"}, "systemMessage": "some reason"}
Trigger the matched tool, then inspect the session JSONL. The record identifies the event and tool ("Hook PreToolUse:Agent") but not which hook, from which plugin or settings file, fired it. The text is identical across every hook on the system.
Why it matters
With several plugin hooks installed, a deny is not attributable after the fact. A hook author can work around this by self-identifying — tagging both permissionDecisionReason (reaches the model) and systemMessage (reaches the user's UI) — but that only covers hooks you control. A denial from a third-party plugin hook has no workaround.
Concretely: this cost us a two-session misdiagnosis, where denials from one of our own guards were attributed to a client-side permission bug. We posted, then retracted, evidence on #73434 — post-mortem at https://github.com/anthropics/claude-code/issues/73434#issuecomment-5130710677. Persisted hook identity would have closed it in seconds.
Suggested fix
Persist decisionReason in the transcript denial record — hookName at minimum, hookSource where populated. (hookSource appears on the PreToolUse:* path; the PermissionRequest hook paths carry hookName and reason only.)
Optionally, include the hook identity in the tool_result content, so the model can react sensibly when a hook author omitted permissionDecisionReason.
— drafted by Claude (Opus 5); reviewed and filed with operator approval
3 Comments
A second measurement on the same deny path, from chasing why a guard of ours reported zero friction: the deny is missing not just its identity, but its decision record. An
allowand anaskeach leave aPreToolUse:*attachment carrying the hook'sstdout; adenyleaves nothing.Across
~/.claude/projects/**/*.jsonl(601 files, 2.1.220, macOS), parsingstdouton everyPreToolUse:Bashattachment:Not one deny in 48k attachments, while 132 denials sat in the same window as error tool results. Narrowing to individual hooks makes the asymmetry concrete:
On all 92 of that hook's denials, it has no attachment of its own. The
allowrows sitting on those same tool calls belong to a different hook in the same chain, which is a good way to lose an afternoon: the denied call looks decided, by someone else.Why this is worth persisting even after
hookNamelands. The ask in this issue is that a deny record carry who denied. This is the adjacent one: that a deny be a decision record at all, in the same stream as allow and ask. Today a hook author can count their allows and asks and cannot count their denies, so any measurement over their own decisions silently omits them — and it omits them in exactly the sessions where denies dominate.auto,dontAsk, andbypassPermissionsturn asks into denies, so a hook measured across unattended sessions reports the least friction precisely where it caused the most. Pointed at another of our guards, the same tool reported 1.5% friction for a week whose 25 blocked calls were every one of them denials.The self-identification workaround you note here does partly cover it, and it is what we ended up shipping: our reason text opens with the hook's name, so the denials are recoverable from the
tool_resultstring by matching our own prefix (karlkfi/claude-foreground-guard#25, fixed in karlkfi/claude-foreground-guard#26). It only works because we control the wording, it string-matches a message intended for a model rather than a parser, and it recovers nothing for the other three guards on this machine, whose reasons are phrased differently.Both gaps close with one change: write the constructed
decisionReasoninto the decision stream on a deny, the waystdoutis written on an allow or an ask.— drafted by Claude (Opus 5); reviewed and posted with operator approval
A third measurement, and it reproduces — linux (the platform this issue was filed from), Claude Code 2.1.232, measured 2026-08-14 over this machine's own transcript tree. Same shape as the macOS numbers above, and one finding that is a step worse than what this issue currently describes.
Method, so it can be re-run rather than trusted
One pass over
~/.claude/projects/**/*.jsonl, 979 transcripts, 0 unreadable, 0 malformed lines. Three counts, nothing derived. Denials are identified by two structural signatures rather than by any one hook's vocabulary:PreToolUse:<Tool> hook error:(Claude Code's own wrapper when the hook process itself errored) and[<namespace/hook-name>](a hook surfacing a structured reason directly as the tool_result). Attachments are correlated to calls bytoolUseID/tool_use_id, never by text matching.1.
PreToolUse:*attachments — 6,1712. Decisions parsed from those attachments'
stdoutThe denominator matters and is not 6,171. Of the 6,171 attachments, 2,838 carry a
stdoutfield at all; the other 3,333 carry none, so no decision can be read from them either way. Among the 2,838:Per hook, the
askrows spread acrossEdit(106),Agent(17),Bash(15),Write(6); everydenycell is 0.3. Denials visible only as
tool_resulterror text — 1,567, and that is a floorA floor rather than a total, stated because we measured it rather than assumed the two signatures were exhaustive: 134 further error results mention a hook and match neither signature. Not all of those are missed denials — a failing
gitpre-push prints hook text into a Bash error too — but they are unclassified, so the real number is 1,567 or more, never less.This machine denies deliberately and often (a set of local PreToolUse guards), which is why the denial population here is large where the macOS run's was 132: the shape is the same, the exposure is bigger.
The part that is worse than "the reason is discarded"
1,339 of the 1,567 error-only denials have no
PreToolUseattachment on the call at all. Not a missingdecisionReason, not a decision recorded as something else — no attachment record of any kind for that tool call. For one hook it is total:message-payload-gate, which denies theSendMessagetool, accounts for 1,061 of them and carries zero attachments across all 1,061.So on this platform the failure has two tiers, and this issue currently names only the first:
deny= 0 across 6,171 attachments);For anyone building on transcripts, tier 2 is the one that bites: a consumer counting decisions sees a clean record with no denials in it, which is exactly what a session with no denials looks like. We hit that ourselves — an audit of our own guards read "0 denies" off the attachments while 1,567 denials sat in the same files as error text.
What we did not check
Whether the
allowrows on denied calls belong to a different hook in the chain — the sharpest finding in the macOS comment — is not answerable from our data: our denials mostly have no attachment at all, so there is noallowrow to attribute. Our 228 error-only denials that do have an attachment could carry that shape, and we did not group them that way. Also, these numbers are a snapshot: the tree grows while sessions run (the count moved by 8 between two runs an hour apart), so treat them as an order of magnitude with a date, not a constant.Happy to re-run with any grouping that would help, or to post the parser if it is useful to reproduce elsewhere.
A fourth measurement, and it narrows the ask rather than widening it: the attributable record already exists in the transcript format, and the
PreToolUsedeny path is the one place it is not written.hook_blocking_erroris an attachment type whoseblockingErrorobject carries the hook's owncommandstring. That string is the script path, so it identifies the plugin without any cooperation from the hook author. It is written today when aStophook blocks:That is the record this issue is asking a deny to produce. A
Stophook that blocks is attributable after the fact. APreToolUsehook that denies is not.Method, so it can be re-run. One pass over
~/.claude/projects/**/*.jsonlon macOS: 1,426 transcripts, 0 unreadable, 0 malformed lines, spanning Claude Code 2.1.219 through 2.1.246 (11 distinct versions). Counthook_blocking_errorattachments grouped byhookEvent, and check whether each carriescommand.Positive control, because a zero and a broken probe read alike.
PreToolUseattachments are not scarce in the same window. The same pass counted 94,330hook_success, 5,492hook_non_blocking_error, 10hook_additional_contextand 2hook_cancelled, every one of them on aPreToolUse:*event. The event is well represented in these files.hook_blocking_erroris the one attachment type that never appears on it.Second control, because the first one only shows the event key is alive. That
PreToolUseattachments are abundant does not establish that anyPreToolUsehook ever denied in the window — and over a corpus where the triggering event never occurred, a zero is alive and vacuous. So: did denials happen?A later, larger read of the same tree, stated separately because the corpus grows while sessions run. Counting
tool_resultstrings inuserrecords whose text opens with a guard's own name —^(?:Error:\s*)?([a-z0-9-]+-guard):\s, matched at position 0:That is a lower bound, and the reason it is one is this issue's own subject: it can only find denials whose reason string happens to open with the plugin's name. A hook that does not name itself is uncountable here, which is the problem being reported.
So the record had at least 1,255 opportunities to be written, and was written zero times. The
PreToolUsezero above is a real absence rather than an artifact of nothing having happened.What this changes. The suggested fix here reads as adding a field to a record. It is smaller than that. A record type that already carries hook identity, and is already emitted when a hook blocks at
Stop, is simply not emitted when a hook blocks atPreToolUse. Gunther-Schulz's tier 2 above, where a denial leaves no attachment of any kind, closes with the shape already in the format: emithook_blocking_erroron aPreToolUsedeny, with the sameblockingErrorandcommandpair thatStopalready gets. Tier 1, the discardeddecisionReason, still wantshookNameandhookSourcepersisted on the denial record itself.Worth noting the two are independent. Persisting
decisionReasonfixes attribution for anything that reaches the denial record, and a deny that writes no attachment at all is still invisible to any consumer that reads decisions structurally rather than by string-matching prose.— drafted by Claude (Opus 5); reviewed and posted with operator approval