diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 7e37f1fba0..74b1e6f5c0 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -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) { diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 9b48e6d645..fb8b11e739 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -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', () => { diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index b61e60bdfb..e3e2ac020b 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -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