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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user