fix(hook-protocol): discard a hookSpecificOutput block whose hookEventName mismatches the firing event

The reference schemas key the `hookSpecificOutput` block by `hookEventName`, so
a block naming a DIFFERENT event than the one firing is malformed — a hook
emitting `hookSpecificOutput.hookEventName: "PreToolUse"` on a `Stop` event must
not deny the Stop. The codec surfaced `hookEventName` for a bridge to compare but
never enforced the discard, so both bridges pushed every parsed output into the
merge unconditionally.

parseHookOutput now takes an optional `expectedEventName`; when the block's
`hookEventName` names a different event, its event-scoped fields
(permissionDecision/permissionDecisionReason/additionalContext/updatedInput) are
discarded (the discriminator is still surfaced for the log, and the
event-agnostic top-level decision/continue/etc. are unaffected). runHook threads
it via RunHookOptions.expectedEventName; a caller that omits it opts out.

Codex review finding on the bridges PR (PR-F); fixed here on the codec that owns
the fold and knows field provenance, then flows down to both bridges.
This commit is contained in:
Tianyi Cui
2026-07-01 10:45:58 +08:00
parent c28d6b837b
commit 24e9c0fa70
6 files changed
+107 -13

No files matched your search

+30 -7
View File
@@ -64,8 +64,19 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision']
* JSON on a 0 exit is treated as "no structured output" (the plain stdout is
* still on the bridge to use), matching both reference engines' lenient parse of
* non-JSON stdout.
*
* `expectedEventName` is the event the hook is FIRING for (e.g. `'PreToolUse'`).
* The reference schemas key the `hookSpecificOutput` block by `hookEventName`,
* so a block whose `hookEventName` names a DIFFERENT event is malformed and its
* event-scoped fields (`permissionDecision`/`permissionDecisionReason`/
* `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a
* `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still
* surfaced (for the log/diagnostics), and the event-agnostic top-level fields
* (`decision`/`reason`/`continue`/`stopReason`/`suppressOutput`/`systemMessage`)
* are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the
* block as-is — a caller that doesn't key by event opts out of the check.
*/
export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string): HookOutput {
export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput {
const trimmedErr = stderr.trim()
const trimmedOut = stdout.trim()
// Keep the raw stdout verbatim: a clean-exit hook may emit PLAIN text the
@@ -96,15 +107,20 @@ export function parseHookOutput(exitCode: number | undefined, stdout: string, st
// reference engines are). The plain stdout remains the bridge's to use.
parsed = undefined
}
if (parsed) applyStructured(output, parsed)
if (parsed) applyStructured(output, parsed, expectedEventName)
}
}
return output
}
/** Fold a parsed structured-stdout object into `output` (mutates in place). */
function applyStructured(output: HookOutput, parsed: Record<string, unknown>): void {
/**
* 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).
*/
function applyStructured(output: HookOutput, parsed: Record<string, unknown>, expectedEventName?: string): void {
const cont = bool(parsed, 'continue')
if (cont !== undefined) output.continue = cont
const stopReason = str(parsed, 'stopReason')
@@ -121,15 +137,22 @@ function applyStructured(output: HookOutput, parsed: Record<string, unknown>): v
const topReason = str(parsed, 'reason')
if (topReason !== undefined) output.reason = topReason
// hookSpecificOutput: the per-event channel, keyed by `hookEventName`. We
// surface that discriminator so the bridge can DISCARD a block whose event
// doesn't match the firing one (the schemas make it the discriminator). The
// hookSpecificOutput: the per-event channel, keyed by `hookEventName`. The
// permissionDecision (allow/deny/ask) OVERRIDES the legacy top-level decision;
// additionalContext and updatedInput live here too.
const hso = obj(parsed.hookSpecificOutput)
if (hso) {
const eventName = str(hso, 'hookEventName')
// 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) {
return
}
const permission = permissionDecisionOf(str(hso, 'permissionDecision'))
if (permission !== undefined) output.decision = permission
const permissionReason = str(hso, 'permissionDecisionReason')