fix(hook-protocol): discard a discriminator-less hookSpecificOutput block

Address review on the hook-protocol PR: the event-scope guard only rejected a
`hookSpecificOutput` block whose `hookEventName` NAMED a different event than
the firing one. A block with NO `hookEventName` slipped through and applied its
event-scoped permission fields to whatever event was firing. Under the keyed
Claude Code schema (where `hookEventName` is part of the block) a missing
discriminator is as malformed as a mismatched one — a Stop/UserPromptSubmit
hook emitting a bare `{ permissionDecision: 'deny' }` could deny the current
point.

Drop the `eventName !== undefined` clause so the guard fires on both a
mismatch and an omission when the caller passes `expectedEventName`; the
opt-out (no expectedEventName) still applies a discriminator-less block as-is.
Flipped the test that pinned the old behavior (it documented an artifact, not
a contract) and proved the corrected one red on the old guard.
This commit is contained in:
Tianyi Cui
2026-07-02 17:13:42 +08:00
parent 9c60fa83f9
commit 9428acdc96
3 changed files with 26 additions and 11 deletions

View File

@@ -117,8 +117,8 @@ export function parseHookOutput(exitCode: number | undefined, stdout: string, st
/**
* Fold a parsed structured-stdout object into `output` (mutates in place).
* `expectedEventName` (the firing event) gates the per-event `hookSpecificOutput`
* block: a block whose `hookEventName` names a different event has its
* event-scoped fields discarded (only its `hookEventName` is recorded).
* block: a block whose `hookEventName` names a different event — OR omits it — has
* its event-scoped fields discarded (any present `hookEventName` is still recorded).
*/
function applyStructured(output: HookOutput, parsed: Record<string, unknown>, expectedEventName?: string): void {
const cont = bool(parsed, 'continue')
@@ -146,11 +146,14 @@ function applyStructured(output: HookOutput, parsed: Record<string, unknown>, ex
// Always surface the discriminator (for the log/diagnostics), even on a
// mismatch — the record should show what the malformed block claimed.
if (eventName !== undefined) output.hookEventName = eventName
// The schemas key this block by event: if it names a DIFFERENT event than the
// one firing, it is malformed — discard its event-scoped fields (a PreToolUse
// block must not deny a Stop hook). A caller that passes no expectedEventName
// opts out of the check (applies the block as-is).
if (expectedEventName !== undefined && eventName !== undefined && eventName !== expectedEventName) {
// The schemas key this block by event: when a caller passes the firing event
// (`expectedEventName`), the block's `hookEventName` MUST name it. A different
// name — or a MISSING one — is malformed under the keyed schema, so discard the
// event-scoped fields (a PreToolUse block must not deny a Stop hook; nor may a
// discriminator-less block silently apply PreToolUse-scoped permission fields to
// whatever event is firing). A caller that passes no expectedEventName opts out
// of the check (applies the block as-is).
if (expectedEventName !== undefined && eventName !== expectedEventName) {
return
}
const permission = permissionDecisionOf(str(hso, 'permissionDecision'))