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 changed files with 107 additions and 13 deletions

View File

@@ -93,6 +93,54 @@ describe('parseHookOutput — structured stdout (exit 0 only)', () => {
expect(parseHookOutput(0, JSON.stringify({ decision: 'maybe' }), '').decision).toBeUndefined()
})
it('DISCARDS a hookSpecificOutput block whose hookEventName mismatches the firing event', () => {
// A PreToolUse block emitted on a Stop hook is malformed — its event-scoped
// fields must not take effect (a stray PreToolUse deny must not deny the Stop).
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'no', additionalContext: 'x', updatedInput: { command: 'y' } },
}), '', 'Stop')
expect(out.hookEventName).toBe('PreToolUse') // still recorded for the log
expect(out.decision).toBeUndefined() // event-scoped fields discarded
expect(out.reason).toBeUndefined()
expect(out.additionalContext).toBeUndefined()
expect(out.updatedInput).toBeUndefined()
})
it('APPLIES a hookSpecificOutput block whose hookEventName matches the firing event', () => {
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', additionalContext: 'x' },
}), '', 'PreToolUse')
expect(out.decision).toBe('deny')
expect(out.additionalContext).toBe('x')
})
it('applies the block when expectedEventName is omitted (opt-out) even if it names an event', () => {
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' },
}), '')
expect(out.decision).toBe('deny')
})
it('applies a block that has NO hookEventName regardless of expectedEventName', () => {
// No discriminator to mismatch — the block applies (a hook that omits the key).
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { permissionDecision: 'deny' },
}), '', 'Stop')
expect(out.decision).toBe('deny')
})
it('a mismatched block does NOT discard the event-agnostic top-level decision/continue', () => {
// Only the per-event block is scoped; top-level fields are event-agnostic.
const out = parseHookOutput(0, JSON.stringify({
decision: 'block', reason: 'top', continue: false, stopReason: 'halt',
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
}), '', 'Stop')
expect(out.decision).toBe('block') // top-level survives; the allow block was discarded
expect(out.reason).toBe('top')
expect(out.continue).toBe(false)
expect(out.stopReason).toBe('halt')
})
it('malformed JSON on a clean exit is lenient (no structured output, no throw)', () => {
const out = parseHookOutput(0, '{ not valid json', '')
expect(out.decision).toBeUndefined()

View File

@@ -131,4 +131,17 @@ describe('runHook — outcome decoding + duration', () => {
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.stderr).toBe('plain string fault')
})
it('threads expectedEventName so a mismatched hookSpecificOutput block is discarded', async () => {
const { bash } = recordingBash(async () => result({
exitCode: 0,
stdout: { text: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), truncated: false },
}))
const { output } = await runHook(bash, { command: 'h' }, {
payload: {}, defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop',
}, clock())
// A PreToolUse block on a Stop hook is malformed → its decision is discarded.
expect(output.hookEventName).toBe('PreToolUse')
expect(output.decision).toBeUndefined()
})
})