PreToolUse hook denials discard `decisionReason` at transcript-write time — the denying hook is unidentifiable after the fact

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 3 comments · opened Jul 30, 2026

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

View original on GitHub ↗

3 Comments

karlkfi · 17 days ago

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 allow and an ask each leave a PreToolUse:* attachment carrying the hook's stdout; a deny leaves nothing.

Across ~/.claude/projects/**/*.jsonl (601 files, 2.1.220, macOS), parsing stdout on every PreToolUse:Bash attachment:

attachments                        : 48404
decisions parsed from stdout       : [('allow', 47081), ('ask', 1279), (empty, 44)]
                                     [('deny', 0)]
denials visible as tool_result text: 132

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:

  38641  ('workspace-guard', 'allow')     <- emits an explicit allow
    730  ('workspace-guard', 'ask')
    256  ('foreground-guard', 'ask')      <- 92 denies in the same window, none recorded

On all 92 of that hook's denials, it has no attachment of its own. The allow rows 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 hookName lands. 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, and bypassPermissions turn 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_result string 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 decisionReason into the decision stream on a deny, the way stdout is written on an allow or an ask.

— drafted by Claude (Opus 5); reviewed and posted with operator approval

Gunther-Schulz · 16 days ago

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 by toolUseID / tool_use_id, never by text matching.

1. PreToolUse:* attachments — 6,171

PreToolUse:Bash    4104
PreToolUse:Agent   1209
PreToolUse:Edit     745
PreToolUse:Skill     85
PreToolUse:Write     27
PreToolUse:Read       1

2. Decisions parsed from those attachments' stdout

The denominator matters and is not 6,171. Of the 6,171 attachments, 2,838 carry a stdout field at all; the other 3,333 carry none, so no decision can be read from them either way. Among the 2,838:

allow          1
ask          144
deny           0        <- same as the macOS run
no decision  2693        (stdout present and parseable, no permissionDecision)
unparseable    0

Per hook, the ask rows spread across Edit (106), Agent (17), Bash (15), Write (6); every deny cell is 0.

3. Denials visible only as tool_result error text — 1,567, and that is a floor

dispatch-guards/message-payload-gate   1061
dispatch-guards/push-claim-reminder     137
PreToolUse:Bash  (crash wrapper)        123
PreToolUse:Edit  (crash wrapper)         93
dispatch-guards/brief-reminder           55
PreToolUse:Agent (crash wrapper)         42
dispatch-guards/subagent-push-gate       28
dispatch-guards/dispatch-skill-gate      14
skill-craft/plugin-stale-gate             6
PreToolUse:Write (crash wrapper)          4
dispatch-guards/amend-gate                4

A 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 git pre-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 PreToolUse attachment on the call at all. Not a missing decisionReason, 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 the SendMessage tool, 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:

  1. a hook denies, an attachment exists, and the decision does not survive into it (deny = 0 across 6,171 attachments);
  2. a hook denies and nothing is written at all — the denial exists only as prose inside the tool's error result, where it is unparseable by anything that reads decisions structurally.

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 allow rows 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 no allow row 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.

karlkfi · 3 days ago

A fourth measurement, and it narrows the ask rather than widening it: the attributable record already exists in the transcript format, and the PreToolUse deny path is the one place it is not written.

hook_blocking_error is an attachment type whose blockingError object carries the hook's own command string. That string is the script path, so it identifies the plugin without any cooperation from the hook author. It is written today when a Stop hook blocks:

{
  "type": "hook_blocking_error",
  "hookName": "Stop",
  "hookEvent": "Stop",
  "blockingError": {
    "blockingError": "pr-sentinel: you are ending your turn with an open pull request #1236 ...",
    "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/pr-sentinel-stop-hook.py\""
  }
}

That is the record this issue is asking a deny to produce. A Stop hook that blocks is attributable after the fact. A PreToolUse hook that denies is not.

Method, so it can be re-run. One pass over ~/.claude/projects/**/*.jsonl on macOS: 1,426 transcripts, 0 unreadable, 0 malformed lines, spanning Claude Code 2.1.219 through 2.1.246 (11 distinct versions). Count hook_blocking_error attachments grouped by hookEvent, and check whether each carries command.

hook_blocking_error, by hookEvent
  Stop           248      command present: 248
  PostToolUse      2      command present:   2
  PreToolUse       0

Positive control, because a zero and a broken probe read alike. PreToolUse attachments are not scarce in the same window. The same pass counted 94,330 hook_success, 5,492 hook_non_blocking_error, 10 hook_additional_context and 2 hook_cancelled, every one of them on a PreToolUse:* event. The event is well represented in these files. hook_blocking_error is the one attachment type that never appears on it.

Second control, because the first one only shows the event key is alive. That PreToolUse attachments are abundant does not establish that any PreToolUse hook 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_result strings in user records whose text opens with a guard's own name — ^(?:Error:\s*)?([a-z0-9-]+-guard):\s, matched at position 0:

guard denials          1,255   across 421 transcripts
  exit-status-guard         766
  foreground-guard          238
  workspace-guard           173
  prod-guard                 44
  branch-guard               21
  spill-guard                 8
  claude-workspace-guard      5

population             157,899 tool_result blocks
                         1,438 transcripts, 0 unreadable, 0 malformed
read at                2026-08-27T23:47Z

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 PreToolUse zero 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 at PreToolUse. Gunther-Schulz's tier 2 above, where a denial leaves no attachment of any kind, closes with the shape already in the format: emit hook_blocking_error on a PreToolUse deny, with the same blockingError and command pair that Stop already gets. Tier 1, the discarded decisionReason, still wants hookName and hookSource persisted on the denial record itself.

Worth noting the two are independent. Persisting decisionReason fixes 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