fix(hooks): pass expectedEventName so a mismatched hookSpecificOutput block is discarded
Wire the bridges to the codec's new discriminator check (merged down from dsh-hook-protocol): each bridge passes its firing `point` as `expectedEventName` to runHook, so a hook whose `hookSpecificOutput.hookEventName` names a different event has its event-scoped fields discarded. Bridge-level guard test: a PreToolUse hook emitting a UserPromptSubmit-labeled deny no longer denies the tool (proven red without the wiring, then reverted).
This commit is contained in:
@@ -141,6 +141,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
defaultTimeoutMs,
|
||||
trailingNewline: true,
|
||||
// Discard a `hookSpecificOutput` block whose `hookEventName` names a
|
||||
// different event than the one firing (the schemas key it by event).
|
||||
expectedEventName: point,
|
||||
}, () => performance.now())
|
||||
outputs.push(output)
|
||||
if (output.updatedInput !== undefined) {
|
||||
|
||||
@@ -387,6 +387,22 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => {
|
||||
// The block's hookEventName (UserPromptSubmit) mismatches the firing event
|
||||
// (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs.
|
||||
const d = dir()
|
||||
const s = sh(d, 'x.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — executor reject + no-open-turn', () => {
|
||||
|
||||
@@ -107,6 +107,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
defaultTimeoutMs,
|
||||
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
|
||||
// Discard a `hookSpecificOutput` block naming a different event.
|
||||
expectedEventName: point,
|
||||
}, () => performance.now())
|
||||
// Codex's SessionStart/UserPromptSubmit treat a clean hook's PLAIN
|
||||
// (non-JSON) stdout as additionalContext. The codec keeps that raw text on
|
||||
|
||||
Reference in New Issue
Block a user