diff --git a/integration-tests/hook-integration/hooks.test.ts b/integration-tests/hook-integration/hooks.test.ts new file mode 100644 index 0000000000..f134dc1abd --- /dev/null +++ b/integration-tests/hook-integration/hooks.test.ts @@ -0,0 +1,1946 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig, validateModelOutput } from '../test-helper.js'; + +/** + * Hooks System Integration Tests + * + * Tests for complete hook system flow including: + * - UserPromptSubmit hooks: Triggered before prompt is sent to LLM + * - Stop hooks: Triggered when agent is about to stop + * + * Test categories: + * - Single hook scenarios (allow, block, modify, context, etc.) + * - Multiple hooks scenarios (parallel, sequential, mixed) + * - Error handling (timeout, missing command, exit codes) + * - Combined hooks (multiple hook types in same session) + */ +describe('Hooks System Integration', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + // ========================================================================== + // UserPromptSubmit Hooks + // Triggered before user prompt is sent to the LLM for processing + // ========================================================================== + describe('UserPromptSubmit Hooks', () => { + describe('Allow Decision', () => { + it('should allow prompt when hook returns allow decision', async () => { + const hookScript = + "console.log(JSON.stringify({decision: 'allow', reason: 'approved by hook'}));"; + + await rig.setup('ups-allow-decision', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${hookScript}"`, + name: 'ups-allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should allow tool execution with allow decision and verify tool was called', async () => { + const hookScript = + "console.log(JSON.stringify({decision: 'allow', reason: 'Tool execution approved'}));"; + + await rig.setup('ups-allow-tool', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${hookScript}"`, + name: 'ups-allow-tool-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + await rig.run('Create a file test.txt with content "hello"'); + + const foundToolCall = await rig.waitForToolCall('write_file'); + expect(foundToolCall).toBeTruthy(); + + const fileContent = rig.readFile('test.txt'); + expect(fileContent).toContain('hello'); + }); + }); + + describe('Block Decision', () => { + it('should block prompt when hook returns block decision', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Prompt blocked by security policy'}));`; + + await rig.setup('ups-block-decision', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + + // Blocked prompts should show the block reason + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block tool execution when hook returns block and verify no tool was called', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'File writing blocked by security policy'}));`; + + await rig.setup('ups-block-tool', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-block-tool-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file test.txt with "hello"'); + + // Tool should not be called due to blocking hook + const toolLogs = rig.readToolLogs(); + const writeFileCalls = toolLogs.filter( + (t) => + t.toolRequest.name === 'write_file' && + t.toolRequest.success === true, + ); + expect(writeFileCalls).toHaveLength(0); + + // Result should mention the blocking reason + expect(result).toContain('block'); + }); + }); + + describe('Modify Prompt', () => { + it('should use modified prompt when hook provides modification', async () => { + const modifyScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'UserPromptSubmit', modifiedPrompt: 'Modified prompt content', additionalContext: 'Context added by hook'}}));`; + + await rig.setup('ups-modify-prompt', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${modifyScript}"`, + name: 'ups-modify-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say test'); + expect(result).toBeDefined(); + }); + }); + + describe('Additional Context', () => { + it('should include additional context in response when hook provides it', async () => { + const contextScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'Extra context information from hook'}}));`; + + await rig.setup('ups-add-context', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${contextScript}"`, + name: 'ups-context-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('What is 1+1?'); + expect(result).toBeDefined(); + }); + }); + + describe('Timeout Handling', () => { + it('should continue execution when hook times out', async () => { + await rig.setup('ups-timeout', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'sleep 60', + name: 'ups-timeout-hook', + timeout: 1000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say timeout test'); + // Should continue despite timeout + expect(result).toBeDefined(); + }); + }); + + describe('Error Handling', () => { + it('should continue execution when hook exits with non-blocking error (exit code 1)', async () => { + await rig.setup('ups-nonblocking-error', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'echo warning && exit 1', + name: 'ups-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say error test'); + // Non-blocking error should not prevent execution + expect(result).toBeDefined(); + }); + + it('should block execution when hook exits with blocking error (exit code 2)', async () => { + await rig.setup('ups-blocking-error', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: + 'node -e "console.error(\'Critical security error\'); process.exit(2)"', + name: 'ups-blocking-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + expect(result).toBeDefined(); + }); + + it('should continue execution when hook command does not exist', async () => { + await rig.setup('ups-missing-command', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '/nonexistent/command/path', + name: 'ups-missing-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say missing test'); + // Missing command should not prevent execution (non-blocking) + expect(result).toBeDefined(); + }); + }); + + describe('Input Format Validation', () => { + it('should receive properly formatted input when hook is called', async () => { + const inputValidationScript = ` +const input = JSON.parse(process.argv[2] || '{}'); +const hasRequired = input.session_id && input.cwd && input.hook_event_name && input.prompt !== undefined; +console.log(JSON.stringify({ + decision: 'allow', + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: hasRequired ? 'Valid input format' : 'Invalid input format' + } +})); +`; + + await rig.setup('ups-correct-input', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${inputValidationScript.replace(/\n/g, ' ')}"`, + name: 'ups-input-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say input test'); + validateModelOutput(result, 'input test', 'UPS: correct input'); + }); + }); + + describe('System Message', () => { + it('should include system message in response when hook provides it', async () => { + const systemMsgScript = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'This is a system message from hook'}));`; + + await rig.setup('ups-system-message', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${systemMsgScript}"`, + name: 'ups-system-msg-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say system message'); + expect(result).toBeDefined(); + }); + }); + + describe('Multiple UserPromptSubmit Hooks', () => { + it('should block when one of multiple parallel hooks returns block', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Allowed'}));`; + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked by security policy'}));`; + + await rig.setup('ups-multi-one-blocks', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'ups-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // When any hook blocks, the result should reflect the block + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block when first sequential hook returns block', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'First hook blocks'}));`; + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'This should not run'}));`; + + await rig.setup('ups-seq-first-blocks', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-seq-block-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'ups-seq-allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // First hook blocks, second should not run + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block when second sequential hook returns block', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Second hook blocks'}));`; + + await rig.setup('ups-seq-second-blocks', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'ups-seq-first-allow', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-seq-second-block', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // Second hook blocks after first allows + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle multiple hooks all returning allow', async () => { + const allow1Script = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; + const allow2Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Second allows'}));`; + const allow3Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Third allows'}));`; + + await rig.setup('ups-multi-all-allow', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allow1Script}"`, + name: 'ups-allow-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allow2Script}"`, + name: 'ups-allow-2', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allow3Script}"`, + name: 'ups-allow-3', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + // All hooks allow, should complete normally + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle multiple hooks all returning block', async () => { + const block1Script = `console.log(JSON.stringify({decision: 'block', reason: 'First blocks'}));`; + const block2Script = `console.log(JSON.stringify({decision: 'block', reason: 'Second blocks'}));`; + + await rig.setup('ups-multi-all-block', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${block1Script}"`, + name: 'ups-block-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${block2Script}"`, + name: 'ups-block-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // All hooks block + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should concatenate additional context from multiple hooks', async () => { + const context1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context from hook 1'}}));`; + const context2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context from hook 2'}}));`; + + await rig.setup('ups-multi-context', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${context1Script}"`, + name: 'ups-context-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${context2Script}"`, + name: 'ups-context-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + + it('should handle hook with error alongside blocking hook', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked'}));`; + + await rig.setup('ups-error-with-block', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '/nonexistent/command', + name: 'ups-error-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // Block should still work despite error in other hook + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle hook timeout alongside blocking hook', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked while other times out'}));`; + + await rig.setup('ups-timeout-with-block', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'sleep 60', + name: 'ups-timeout-hook', + timeout: 1000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // Block should work despite timeout in other hook + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle multiple hook groups with different configurations', async () => { + const allow1Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Group 1 allows'}));`; + const allow2Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Group 2 allows'}));`; + + await rig.setup('ups-multi-groups', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allow1Script}"`, + name: 'ups-group1-hook', + timeout: 5000, + }, + ], + }, + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${allow2Script}"`, + name: 'ups-group2-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + + it('should block when one group blocks in multiple hook groups', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Group 1 allows'}));`; + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Group 2 blocks'}));`; + + await rig.setup('ups-multi-groups-one-blocks', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'ups-group1-allow', + timeout: 5000, + }, + ], + }, + { + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-group2-block', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // One group blocks, should be blocked + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle modified prompt from multiple hooks', async () => { + const modify1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {modifiedPrompt: 'Modified by hook 1'}}));`; + const modify2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {modifiedPrompt: 'Modified by hook 2'}}));`; + + await rig.setup('ups-multi-modify', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${modify1Script}"`, + name: 'ups-modify-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${modify2Script}"`, + name: 'ups-modify-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + + it('should handle system messages from multiple hooks', async () => { + const msg1Script = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'System message 1'}));`; + const msg2Script = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'System message 2'}));`; + + await rig.setup('ups-multi-system-msg', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${msg1Script}"`, + name: 'ups-msg-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${msg2Script}"`, + name: 'ups-msg-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + }); + }); + + // ========================================================================== + // Stop Hooks + // Triggered when the agent is about to stop execution + // ========================================================================== + describe('Stop Hooks', () => { + describe('Allow Decision', () => { + it('should allow stopping when hook returns allow decision', async () => { + const allowStopScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Stop allowed'}));`; + + await rig.setup('stop-allow', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowStopScript}"`, + name: 'stop-allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say stop test'); + expect(result).toBeDefined(); + }); + + it('should allow stopping and verify final response is produced', async () => { + const allowFinalScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'Final context from stop hook'}}));`; + + await rig.setup('stop-allow-final', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowFinalScript}"`, + name: 'stop-final-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say goodbye'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('Block Decision', () => { + it('should block stopping when hook returns block decision', async () => { + const blockStopScript = `console.log(JSON.stringify({decision: 'block', reason: 'Stop blocked by security policy'}));`; + + await rig.setup('stop-block-decision', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockStopScript}"`, + name: 'stop-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + // Blocked stop should show the block reason + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block stopping with custom reason', async () => { + const blockReasonScript = `console.log(JSON.stringify({decision: 'block', reason: 'Custom block reason: task incomplete'}));`; + + await rig.setup('stop-block-custom-reason', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockReasonScript}"`, + name: 'stop-block-reason-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say goodbye'); + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + }); + + describe('Continue False', () => { + it('should request continue execution when hook returns continue: false', async () => { + const continueScript = `console.log(JSON.stringify({continue: false, stopReason: 'More work needed'}));`; + + await rig.setup('stop-continue-false', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${continueScript}"`, + name: 'stop-continue-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say continue'); + // When continue: false, the agent may try to continue + expect(result).toBeDefined(); + }); + }); + + describe('Additional Context', () => { + it('should include additional context in final response', async () => { + const contextScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'Final context from hook'}}));`; + + await rig.setup('stop-add-context', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${contextScript}"`, + name: 'stop-context-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('What is 3+3?'); + expect(result).toBeDefined(); + }); + + it('should concatenate multiple additionalContext from multiple hooks', async () => { + const context1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context1'}}));`; + const context2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context2'}}));`; + + await rig.setup('stop-multi-context', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${context1Script}"`, + name: 'stop-context-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${context2Script}"`, + name: 'stop-context-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say multi context'); + expect(result).toBeDefined(); + }); + }); + + describe('Stop Reason', () => { + it('should include stop reason when hook provides it', async () => { + const reasonScript = `console.log(JSON.stringify({decision: 'allow', stopReason: 'Custom stop reason from hook'}));`; + + await rig.setup('stop-set-reason', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${reasonScript}"`, + name: 'stop-reason-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say reason test'); + expect(result).toBeDefined(); + }); + }); + + describe('Timeout Handling', () => { + it('should continue stopping when hook times out', async () => { + await rig.setup('stop-timeout', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: 'sleep 60', + name: 'stop-timeout-hook', + timeout: 1000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say timeout'); + // Timeout should not prevent stopping + expect(result).toBeDefined(); + }); + }); + + describe('Error Handling', () => { + it('should continue stopping when hook has non-blocking error', async () => { + await rig.setup('stop-error', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: 'echo warning && exit 1', + name: 'stop-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say error'); + // Error should not prevent stopping + expect(result).toBeDefined(); + }); + + it('should continue stopping when hook command does not exist', async () => { + await rig.setup('stop-missing-command', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: '/nonexistent/stop/command', + name: 'stop-missing-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say missing'); + // Missing command should not prevent stopping + expect(result).toBeDefined(); + }); + }); + + describe('System Message', () => { + it('should include system message in final response', async () => { + const systemMsgScript = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'Final system message from stop hook'}));`; + + await rig.setup('stop-system-message', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${systemMsgScript}"`, + name: 'stop-system-msg-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say final'); + expect(result).toBeDefined(); + }); + }); + + describe('Multiple Stop Hooks', () => { + it('should block when one of multiple parallel stop hooks returns block', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Stop allowed'}));`; + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Stop blocked by security policy'}));`; + + await rig.setup('stop-multi-one-blocks', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'stop-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say multi stop'); + // When any hook blocks, the result should reflect the block + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block when first sequential stop hook returns block', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'First hook blocks stop'}));`; + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'This should not run'}));`; + + await rig.setup('stop-seq-first-blocks', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-seq-block-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'stop-seq-allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say sequential stop'); + // First hook blocks, second should not run + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block when second sequential stop hook returns block', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Second hook blocks stop'}));`; + + await rig.setup('stop-seq-second-blocks', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'stop-seq-first-allow', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-seq-second-block', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say seq second blocks'); + // Second hook blocks after first allows + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle multiple stop hooks all returning allow', async () => { + const allow1Script = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; + const allow2Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Second allows'}));`; + const allow3Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Third allows'}));`; + + await rig.setup('stop-multi-all-allow', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allow1Script}"`, + name: 'stop-allow-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allow2Script}"`, + name: 'stop-allow-2', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allow3Script}"`, + name: 'stop-allow-3', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say all allow'); + // All hooks allow, should complete normally + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle multiple stop hooks all returning block', async () => { + const block1Script = `console.log(JSON.stringify({decision: 'block', reason: 'First blocks'}));`; + const block2Script = `console.log(JSON.stringify({decision: 'block', reason: 'Second blocks'}));`; + + await rig.setup('stop-multi-all-block', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${block1Script}"`, + name: 'stop-block-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${block2Script}"`, + name: 'stop-block-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say all block'); + // All hooks block + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle multiple continue: false from different stop hooks', async () => { + const continue1Script = `console.log(JSON.stringify({continue: false, stopReason: 'First needs more work'}));`; + const continue2Script = `console.log(JSON.stringify({continue: false, stopReason: 'Second needs more work'}));`; + + await rig.setup('stop-multi-continue-false', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${continue1Script}"`, + name: 'stop-continue-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${continue2Script}"`, + name: 'stop-continue-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say multi continue'); + // Multiple continue: false should be handled + expect(result).toBeDefined(); + }); + + it('should handle mixed allow and continue: false in stop hooks', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Allow stop'}));`; + const continueScript = `console.log(JSON.stringify({continue: false, stopReason: 'Need more work'}));`; + + await rig.setup('stop-mixed-allow-continue', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'stop-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${continueScript}"`, + name: 'stop-continue-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say mixed'); + expect(result).toBeDefined(); + }); + + it('should handle block with higher priority than continue: false', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Security block'}));`; + const continueScript = `console.log(JSON.stringify({continue: false, stopReason: 'Need more work'}));`; + + await rig.setup('stop-block-vs-continue', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-block-priority', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${continueScript}"`, + name: 'stop-continue-lower', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say block priority'); + // Block should take priority + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle stop hook with error alongside blocking hook', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked'}));`; + + await rig.setup('stop-error-with-block', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: '/nonexistent/command', + name: 'stop-error-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say error with block'); + // Block should still work despite error in other hook + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle stop hook timeout alongside blocking hook', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked while other times out'}));`; + + await rig.setup('stop-timeout-with-block', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: 'sleep 60', + name: 'stop-timeout-hook', + timeout: 1000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say timeout with block'); + // Block should work despite timeout in other hook + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + }); + }); + + // ========================================================================== + // Multiple Hooks (General) + // Tests for hook execution modes: sequential vs parallel + // ========================================================================== + describe('Multiple Hooks', () => { + describe('Sequential Execution', () => { + it('should execute hooks sequentially when sequential: true', async () => { + const hook1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'first'}}));`; + const hook2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'second'}}));`; + + await rig.setup('multi-sequential', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${hook1Script}"`, + name: 'seq-hook-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${hook2Script}"`, + name: 'seq-hook-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say sequential'); + expect(result).toBeDefined(); + }); + + it('should stop at first blocking hook and not execute subsequent', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked by first hook'}));`; + const allowScript = `console.log(JSON.stringify({decision: 'allow'}));`; + + await rig.setup('multi-first-blocks', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'seq-block-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'seq-should-not-run', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // First hook blocks, second should not run + expect(result.toLowerCase()).toContain('block'); + }); + + it('should pass output from first hook to second hook input', async () => { + const passScript1 = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'from first', passthrough: 'data'}}));`; + const passScript2 = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'received passthrough'}}));`; + + await rig.setup('multi-passthrough', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${passScript1}"`, + name: 'passthrough-hook-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${passScript2}"`, + name: 'passthrough-hook-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say passthrough'); + expect(result).toBeDefined(); + }); + }); + + describe('Parallel Execution', () => { + it('should execute hooks in parallel when sequential is not set', async () => { + const hook1Script = `console.log(JSON.stringify({decision: 'allow'}));`; + const hook2Script = `console.log(JSON.stringify({decision: 'allow'}));`; + + await rig.setup('multi-parallel', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${hook1Script}"`, + name: 'parallel-hook-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${hook2Script}"`, + name: 'parallel-hook-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say parallel'); + expect(result).toBeDefined(); + }); + + it('should handle mixed success/failure results from parallel hooks', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow'}));`; + + await rig.setup('multi-mixed', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'mixed-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: '/nonexistent/command', + name: 'mixed-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say mixed'); + // Mixed results: one succeeds, one fails - should continue + expect(result).toBeDefined(); + }); + + it('should allow when any hook returns allow in parallel (OR logic)', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'blocked'}));`; + const allowScript = `console.log(JSON.stringify({decision: 'allow'}));`; + + await rig.setup('multi-or-logic', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'block-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say or logic'); + // With OR logic, allow should win + expect(result).toBeDefined(); + }); + }); + }); + + // ========================================================================== + // Combined Hooks + // Tests for using multiple hook types (UserPromptSubmit + Stop) together + // ========================================================================== + describe('Combined Hooks', () => { + it('should execute both Stop and UserPromptSubmit hooks in same session', async () => { + const stopScript = `console.log(JSON.stringify({decision: 'allow'}));`; + const upsScript = `console.log(JSON.stringify({decision: 'allow'}));`; + + await rig.setup('combined-both-hooks', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${stopScript}"`, + name: 'stop-hook', + timeout: 5000, + }, + ], + }, + ], + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${upsScript}"`, + name: 'ups-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say both hooks'); + expect(result).toBeDefined(); + }); + }); + + // ========================================================================== + // Hook Script File Tests + // Tests for executing hooks from external script files + // ========================================================================== + describe('Hook Script File Tests', () => { + it('should execute hook from script file', async () => { + await rig.setup('script-file-hook', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: + "node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Approved by script file', hookSpecificOutput: {additionalContext: 'Script file executed successfully'}}))\"", + name: 'script-file-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say script file test'); + expect(result).toBeDefined(); + }); + + it('should execute blocking hook from script file', async () => { + await rig.setup('script-file-block-hook', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: + "node -e \"console.log(JSON.stringify({decision: 'block', reason: 'Blocked by security script'}))\"", + name: 'script-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + + // Prompt should be blocked + expect(result.toLowerCase()).toContain('block'); + }); + }); +}); diff --git a/packages/cli/src/commands/hooks.tsx b/packages/cli/src/commands/hooks.tsx new file mode 100644 index 0000000000..c747c61c2a --- /dev/null +++ b/packages/cli/src/commands/hooks.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { enableCommand } from './hooks/enable.js'; +import { disableCommand } from './hooks/disable.js'; + +export const hooksCommand: CommandModule = { + command: 'hooks ', + aliases: ['hook'], + describe: 'Manage Qwen Code hooks.', + builder: (yargs) => + yargs + .command(enableCommand) + .command(disableCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/hooks/disable.ts b/packages/cli/src/commands/hooks/disable.ts new file mode 100644 index 0000000000..8d1324cdbf --- /dev/null +++ b/packages/cli/src/commands/hooks/disable.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +const debugLogger = createDebugLogger('HOOKS_DISABLE'); + +interface DisableArgs { + hookName: string; +} + +/** + * Disable a hook by adding it to the disabled list + */ +export async function handleDisableHook(hookName: string): Promise { + const workingDir = process.cwd(); + const settings = loadSettings(workingDir); + + try { + // Get current hooks settings + const mergedSettings = settings.merged as + | Record + | undefined; + const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record< + string, + unknown + >; + const disabledHooks = (hooksSettings['disabled'] || []) as string[]; + + // Check if hook is already disabled + if (disabledHooks.includes(hookName)) { + debugLogger.info(`Hook "${hookName}" is already disabled.`); + return; + } + + // Add hook to disabled list + const newDisabledHooks = [...disabledHooks, hookName]; + const newHooksSettings = { + ...hooksSettings, + disabled: newDisabledHooks, + }; + + // Save updated settings + settings.setValue( + SettingScope.Workspace, + 'hooks' as keyof typeof settings.merged, + newHooksSettings as never, + ); + + debugLogger.info(`✓ Hook "${hookName}" has been disabled.`); + } catch (error) { + debugLogger.error(`Error disabling hook: ${getErrorMessage(error)}`); + } +} + +export const disableCommand: CommandModule = { + command: 'disable ', + describe: 'Disable an active hook', + builder: (yargs) => + yargs.positional('hook-name', { + describe: 'Name of the hook to disable', + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + const args = argv as unknown as DisableArgs; + await handleDisableHook(args.hookName); + process.exit(0); + }, +}; diff --git a/packages/cli/src/commands/hooks/enable.ts b/packages/cli/src/commands/hooks/enable.ts new file mode 100644 index 0000000000..863b5b32ce --- /dev/null +++ b/packages/cli/src/commands/hooks/enable.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +const debugLogger = createDebugLogger('HOOKS_ENABLE'); + +interface EnableArgs { + hookName: string; +} + +/** + * Enable a hook by removing it from the disabled list + */ +export async function handleEnableHook(hookName: string): Promise { + const workingDir = process.cwd(); + const settings = loadSettings(workingDir); + + try { + // Get current hooks settings + const mergedSettings = settings.merged as + | Record + | undefined; + const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record< + string, + unknown + >; + const disabledHooks = (hooksSettings['disabled'] || []) as string[]; + + // Check if hook is in disabled list + if (!disabledHooks.includes(hookName)) { + debugLogger.info(`Hook "${hookName}" is not disabled.`); + return; + } + + // Remove hook from disabled list + const newDisabledHooks = disabledHooks.filter((h) => h !== hookName); + const newHooksSettings = { + ...hooksSettings, + disabled: newDisabledHooks, + }; + + // Save updated settings + settings.setValue( + SettingScope.Workspace, + 'hooks' as keyof typeof settings.merged, + newHooksSettings as never, + ); + + debugLogger.info(`✓ Hook "${hookName}" has been enabled.`); + } catch (error) { + debugLogger.error(`Error enabling hook: ${getErrorMessage(error)}`); + } +} + +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enable a disabled hook', + builder: (yargs) => + yargs.positional('hook-name', { + describe: 'Name of the hook to enable', + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + const args = argv as unknown as EnableArgs; + await handleEnableHook(args.hookName); + process.exit(0); + }, +}; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 48961cdcac..bf9fa51969 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -33,6 +33,7 @@ import { NativeLspService, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; +import { hooksCommand } from '../commands/hooks.js'; import type { Settings } from './settings.js'; import { resolveCliGenerationConfig, @@ -124,6 +125,7 @@ export interface CliArgs { acp: boolean | undefined; experimentalAcp: boolean | undefined; experimentalLsp: boolean | undefined; + experimentalHooks: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; openaiLogging: boolean | undefined; @@ -337,6 +339,12 @@ export async function parseArguments(): Promise { 'Enable experimental LSP (Language Server Protocol) feature for code intelligence', default: false, }) + .option('experimental-hooks', { + type: 'boolean', + description: + 'Enable experimental hooks feature for lifecycle event customization', + default: false, + }) .option('channel', { type: 'string', choices: ['VSCode', 'ACP', 'SDK', 'CI'], @@ -561,7 +569,9 @@ export async function parseArguments(): Promise { // Register MCP subcommands .command(mcpCommand) // Register Extension subcommands - .command(extensionsCommand); + .command(extensionsCommand) + // Register Hooks subcommands + .command(hooksCommand); yargsInstance .version(await getCliVersion()) // This will enable the --version flag based on package.json @@ -580,9 +590,11 @@ export async function parseArguments(): Promise { // and not return to main CLI logic if ( result._.length > 0 && - (result._[0] === 'mcp' || result._[0] === 'extensions') + (result._[0] === 'mcp' || + result._[0] === 'extensions' || + result._[0] === 'hooks') ) { - // MCP commands handle their own execution and process exit + // MCP/Extensions/Hooks commands handle their own execution and process exit process.exit(0); } @@ -1021,6 +1033,10 @@ export async function loadCliConfig( output: { format: outputSettingsFormat, }, + hooks: settings.hooks, + hooksConfig: settings.hooksConfig, + enableHooks: + argv.experimentalHooks === true || settings.hooksConfig?.enabled === true, channel: argv.channel, // Precedence: explicit CLI flag > settings file > default(true). // NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fd6c3e85b0..73c47a6508 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1176,6 +1176,75 @@ const SETTINGS_SCHEMA = { description: 'Configuration for web search providers.', showInDialog: false, }, + + hooksConfig: { + type: 'object', + label: 'Hooks Config', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Hook configurations for intercepting and customizing agent behavior.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Hooks', + category: 'Advanced', + requiresRestart: true, + default: true, + description: + 'Canonical toggle for the hooks system. When disabled, no hooks will be executed.', + showInDialog: false, + }, + disabled: { + type: 'array', + label: 'Disabled Hooks', + category: 'Advanced', + requiresRestart: false, + default: [] as string[], + description: + 'List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + }, + }, + + hooks: { + type: 'object', + label: 'Hooks', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Hook event configurations for extending CLI behavior at various lifecycle points.', + showInDialog: false, + properties: { + UserPromptSubmit: { + type: 'array', + label: 'Before Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before agent processing. Can modify prompts or inject context.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + }, + Stop: { + type: 'array', + label: 'After Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after agent processing. Can post-process responses or log interactions.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + }, + }, + }, } as const satisfies SettingsSchema; export type SettingsSchemaType = typeof SETTINGS_SCHEMA; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 6c48658ade..8c9cd687f9 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -497,6 +497,7 @@ describe('gemini.tsx main function kitty protocol', () => { authType: undefined, maxSessionTurns: undefined, experimentalLsp: undefined, + experimentalHooks: undefined, channel: undefined, chatRecording: undefined, sessionId: undefined, diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index cda06daadc..08ee98eb2b 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -21,6 +21,7 @@ import { editorCommand } from '../ui/commands/editorCommand.js'; import { exportCommand } from '../ui/commands/exportCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; +import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; import { initCommand } from '../ui/commands/initCommand.js'; import { languageCommand } from '../ui/commands/languageCommand.js'; @@ -72,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader { exportCommand, extensionsCommand, helpCommand, + hooksCommand, await ideCommand(), initCommand, languageCommand, diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts new file mode 100644 index 0000000000..04951db7aa --- /dev/null +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -0,0 +1,322 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + SlashCommand, + SlashCommandActionReturn, + CommandContext, + MessageActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; +import type { HookRegistryEntry } from '@qwen-code/qwen-code-core'; + +/** + * Format hook source for display + */ +function formatHookSource(source: string): string { + switch (source) { + case 'project': + return 'Project'; + case 'user': + return 'User'; + case 'system': + return 'System'; + case 'extensions': + return 'Extension'; + default: + return source; + } +} + +/** + * Format hook status for display + */ +function formatHookStatus(enabled: boolean): string { + return enabled ? '✓ Enabled' : '✗ Disabled'; +} + +const listCommand: SlashCommand = { + name: 'list', + get description() { + return t('List all configured hooks'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + _args: string, + ): Promise => { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'info', + content: t( + 'Hooks are not enabled. Enable hooks in settings to use this feature.', + ), + }; + } + + const registry = hookSystem.getRegistry(); + const allHooks = registry.getAllHooks(); + + if (allHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: t( + 'No hooks configured. Add hooks in your settings.json file.', + ), + }; + } + + // Group hooks by event + const hooksByEvent = new Map(); + for (const hook of allHooks) { + const eventName = hook.eventName; + if (!hooksByEvent.has(eventName)) { + hooksByEvent.set(eventName, []); + } + hooksByEvent.get(eventName)!.push(hook); + } + + let output = `**Configured Hooks (${allHooks.length} total)**\n\n`; + + for (const [eventName, hooks] of hooksByEvent) { + output += `### ${eventName}\n`; + for (const hook of hooks) { + const name = hook.config.name || hook.config.command || 'unnamed'; + const source = formatHookSource(hook.source); + const status = formatHookStatus(hook.enabled); + const matcher = hook.matcher ? ` (matcher: ${hook.matcher})` : ''; + output += `- **${name}** [${source}] ${status}${matcher}\n`; + } + output += '\n'; + } + + return { + type: 'message', + messageType: 'info', + content: output, + }; + }, +}; + +const enableCommand: SlashCommand = { + name: 'enable', + get description() { + return t('Enable a disabled hook'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const hookName = args.trim(); + if (!hookName) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Please specify a hook name. Usage: /hooks enable ', + ), + }; + } + + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: t('Hooks are not enabled.'), + }; + } + + const registry = hookSystem.getRegistry(); + registry.setHookEnabled(hookName, true); + + return { + type: 'message', + messageType: 'info', + content: t('Hook "{{name}}" has been enabled for this session.', { + name: hookName, + }), + }; + }, + completion: async (context: CommandContext, partialArg: string) => { + const { config } = context.services; + if (!config) return []; + + const hookSystem = config.getHookSystem(); + if (!hookSystem) return []; + + const registry = hookSystem.getRegistry(); + const allHooks = registry.getAllHooks(); + + // Return disabled hooks for enable command (deduplicated by name) + const disabledHookNames = allHooks + .filter((hook) => !hook.enabled) + .map((hook) => hook.config.name || hook.config.command || '') + .filter((name) => name && name.startsWith(partialArg)); + return [...new Set(disabledHookNames)]; + }, +}; + +const disableCommand: SlashCommand = { + name: 'disable', + get description() { + return t('Disable an active hook'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const hookName = args.trim(); + if (!hookName) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Please specify a hook name. Usage: /hooks disable ', + ), + }; + } + + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: t('Hooks are not enabled.'), + }; + } + + const registry = hookSystem.getRegistry(); + registry.setHookEnabled(hookName, false); + + return { + type: 'message', + messageType: 'info', + content: t('Hook "{{name}}" has been disabled for this session.', { + name: hookName, + }), + }; + }, + completion: async (context: CommandContext, partialArg: string) => { + const { config } = context.services; + if (!config) return []; + + const hookSystem = config.getHookSystem(); + if (!hookSystem) return []; + + const registry = hookSystem.getRegistry(); + const allHooks = registry.getAllHooks(); + + // Return enabled hooks for disable command (deduplicated by name) + const enabledHookNames = allHooks + .filter((hook) => hook.enabled) + .map((hook) => hook.config.name || hook.config.command || '') + .filter((name) => name && name.startsWith(partialArg)); + return [...new Set(enabledHookNames)]; + }, +}; + +export const hooksCommand: SlashCommand = { + name: 'hooks', + get description() { + return t('Manage Qwen Code hooks'); + }, + kind: CommandKind.BUILT_IN, + subCommands: [listCommand, enableCommand, disableCommand], + action: async ( + context: CommandContext, + args: string, + ): Promise => { + // If no subcommand provided, show list + if (!args.trim()) { + const result = await listCommand.action?.(context, ''); + return result ?? { type: 'message', messageType: 'info', content: '' }; + } + + const [subcommand, ...rest] = args.trim().split(/\s+/); + const subArgs = rest.join(' '); + + let result: SlashCommandActionReturn | void; + switch (subcommand.toLowerCase()) { + case 'list': + result = await listCommand.action?.(context, subArgs); + break; + case 'enable': + result = await enableCommand.action?.(context, subArgs); + break; + case 'disable': + result = await disableCommand.action?.(context, subArgs); + break; + default: + return { + type: 'message', + messageType: 'error', + content: t( + 'Unknown subcommand: {{cmd}}. Available: list, enable, disable', + { + cmd: subcommand, + }, + ), + }; + } + return result ?? { type: 'message', messageType: 'info', content: '' }; + }, + completion: async (context: CommandContext, partialArg: string) => { + const subcommands = ['list', 'enable', 'disable']; + const parts = partialArg.split(/\s+/); + + if (parts.length <= 1) { + // Complete subcommand + return subcommands.filter((cmd) => cmd.startsWith(partialArg)); + } + + // Complete subcommand arguments + const [subcommand, ...rest] = parts; + const subArgs = rest.join(' '); + + switch (subcommand.toLowerCase()) { + case 'enable': + return enableCommand.completion?.(context, subArgs) ?? []; + case 'disable': + return disableCommand.completion?.(context, subArgs) ?? []; + default: + return []; + } + }, +}; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 0e5f292168..dd3dc060f7 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1020,6 +1020,15 @@ export const useGeminiStream = ( clearRetryCountdown(); } break; + case ServerGeminiEventType.HookSystemMessage: + // Display system message from hooks (e.g., Ralph Loop iteration info) + // This is handled as a content event to show in the UI + geminiMessageBuffer = handleContentEvent( + event.value + '\n', + geminiMessageBuffer, + userMessageTimestamp, + ); + break; default: { // enforces exhaustive switch-case const unreachable: never = event; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 98b72c9c22..61ec4dfe78 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -84,6 +84,13 @@ import { ExtensionManager, type Extension, } from '../extension/extensionManager.js'; +import { HookSystem } from '../hooks/index.js'; +import { MessageBus } from '../confirmation-bus/message-bus.js'; +import { + MessageBusType, + type HookExecutionRequest, + type HookExecutionResponse, +} from '../confirmation-bus/types.js'; // Utils import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; @@ -377,6 +384,12 @@ export interface ConfigParameters { channel?: string; /** Model providers configuration grouped by authType */ modelProvidersConfig?: ModelProvidersConfig; + /** Enable hook system for lifecycle events */ + enableHooks?: boolean; + /** Hooks configuration from settings */ + hooks?: Record; + /** Hooks config settings (enabled, disabled list) */ + hooksConfig?: Record; /** Warnings generated during configuration resolution */ warnings?: string[]; } @@ -519,6 +532,11 @@ export class Config { private readonly eventEmitter?: EventEmitter; private readonly channel: string | undefined; private readonly defaultFileEncoding: FileEncodingType; + private readonly enableHooks: boolean; + private readonly hooks?: Record; + private readonly hooksConfig?: Record; + private hookSystem?: HookSystem; + private messageBus?: MessageBus; constructor(params: ConfigParameters) { this.sessionId = params.sessionId ?? randomUUID(); @@ -673,6 +691,9 @@ export class Config { enabledExtensionOverrides: this.overrideExtensions, isWorkspaceTrusted: this.isTrustedFolder(), }); + this.enableHooks = params.enableHooks ?? false; + this.hooks = params.hooks; + this.hooksConfig = params.hooksConfig; } /** @@ -696,6 +717,75 @@ export class Config { await this.extensionManager.refreshCache(); this.debugLogger.debug('Extension manager initialized'); + // Initialize hook system if enabled + if (this.enableHooks) { + this.hookSystem = new HookSystem(this); + await this.hookSystem.initialize(); + this.debugLogger.debug('Hook system initialized'); + + // Initialize MessageBus for hook execution + this.messageBus = new MessageBus(); + + // Subscribe to HOOK_EXECUTION_REQUEST to execute hooks + this.messageBus.subscribe( + MessageBusType.HOOK_EXECUTION_REQUEST, + async (request: HookExecutionRequest) => { + try { + const hookSystem = this.hookSystem; + if (!hookSystem) { + this.messageBus?.publish({ + type: MessageBusType.HOOK_EXECUTION_RESPONSE, + correlationId: request.correlationId, + success: false, + error: new Error('Hook system not initialized'), + } as HookExecutionResponse); + return; + } + + // Execute the appropriate hook based on eventName + let result; + const input = request.input || {}; + switch (request.eventName) { + case 'UserPromptSubmit': + result = await hookSystem.fireUserPromptSubmitEvent( + (input['prompt'] as string) || '', + ); + break; + case 'Stop': + result = await hookSystem.fireStopEvent( + (input['stop_hook_active'] as boolean) || false, + (input['last_assistant_message'] as string) || '', + ); + break; + default: + this.debugLogger.warn( + `Unknown hook event: ${request.eventName}`, + ); + result = undefined; + } + + // Send response + this.messageBus?.publish({ + type: MessageBusType.HOOK_EXECUTION_RESPONSE, + correlationId: request.correlationId, + success: true, + output: result, + } as HookExecutionResponse); + } catch (error) { + this.debugLogger.warn(`Hook execution failed: ${error}`); + this.messageBus?.publish({ + type: MessageBusType.HOOK_EXECUTION_RESPONSE, + correlationId: request.correlationId, + success: false, + error: error instanceof Error ? error : new Error(String(error)), + } as HookExecutionResponse); + } + }, + ); + + this.debugLogger.debug('MessageBus initialized with hook subscription'); + } + this.subagentManager = new SubagentManager(this); this.skillManager = new SkillManager(this); await this.skillManager.startWatching(); @@ -1384,6 +1474,66 @@ export class Config { return this.extensionManager; } + /** + * Get the hook system instance if hooks are enabled. + * Returns undefined if hooks are not enabled. + */ + getHookSystem(): HookSystem | undefined { + return this.hookSystem; + } + + /** + * Check if hooks are enabled. + */ + getEnableHooks(): boolean { + return this.enableHooks; + } + + /** + * Get the message bus instance. + * Returns undefined if not set. + */ + getMessageBus(): MessageBus | undefined { + return this.messageBus; + } + + /** + * Set the message bus instance. + * This is called by the CLI layer to inject the MessageBus. + */ + setMessageBus(messageBus: MessageBus): void { + this.messageBus = messageBus; + } + + /** + * Get the list of disabled hook names. + * This is used by the HookRegistry to filter out disabled hooks. + */ + getDisabledHooks(): string[] { + const hooksConfig = this.hooksConfig; + if (!hooksConfig) return []; + const disabled = hooksConfig['disabled']; + return Array.isArray(disabled) ? (disabled as string[]) : []; + } + + /** + * Get project-level hooks configuration. + * This is used by the HookRegistry to load project-specific hooks. + */ + getProjectHooks(): Record | undefined { + // This will be populated from settings by the CLI layer + // The core Config doesn't have direct access to settings + return undefined; + } + + /** + * Get all hooks configuration (merged from all sources). + * This is used by the HookRegistry to load hooks. + */ + getHooks(): Record | undefined { + return this.hooks; + } + getExtensions(): Extension[] { const extensions = this.extensionManager.getLoadedExtensions(); if (this.overrideExtensions) { @@ -1620,6 +1770,21 @@ export class Config { return this.chatRecordingService; } + /** + * Returns the transcript file path for the current session. + * This is the path to the JSONL file where the conversation is recorded. + * Returns empty string if chat recording is disabled. + */ + getTranscriptPath(): string { + if (!this.chatRecordingEnabled) { + return ''; + } + const projectDir = this.storage.getProjectDir(); + const sessionId = this.getSessionId(); + const safeFilename = `${sessionId}.jsonl`; + return path.join(projectDir, 'chats', safeFilename); + } + /** * Gets or creates a SessionService for managing chat sessions. */ diff --git a/packages/core/src/confirmation-bus/message-bus.ts b/packages/core/src/confirmation-bus/message-bus.ts new file mode 100644 index 0000000000..fcd2caab75 --- /dev/null +++ b/packages/core/src/confirmation-bus/message-bus.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { MessageBusType, type Message } from './types.js'; +import { safeJsonStringify } from '../utils/safeJsonStringify.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +export class MessageBus extends EventEmitter { + constructor(private readonly debug = false) { + super(); + this.debug = debug; + } + + private isValidMessage(message: Message): boolean { + if (!message || !message.type) { + return false; + } + + if ( + message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST && + !('correlationId' in message) + ) { + return false; + } + + return true; + } + + private emitMessage(message: Message): void { + this.emit(message.type, message); + } + + async publish(message: Message): Promise { + if (this.debug) { + debugLogger.debug(`[MESSAGE_BUS] publish: ${safeJsonStringify(message)}`); + } + try { + if (!this.isValidMessage(message)) { + throw new Error( + `Invalid message structure: ${safeJsonStringify(message)}`, + ); + } + + if (message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST) { + // Allow all tool confirmations by default (policy engine removed) + this.emitMessage({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: message.correlationId, + confirmed: true, + }); + } else if (message.type === MessageBusType.HOOK_EXECUTION_REQUEST) { + // Allow all hook executions by default (policy engine removed) + this.emitMessage(message); + } else { + // For all other message types, just emit them + this.emitMessage(message); + } + } catch (error) { + this.emit('error', error); + } + } + + subscribe( + type: T['type'], + listener: (message: T) => void, + ): void { + this.on(type, listener); + } + + unsubscribe( + type: T['type'], + listener: (message: T) => void, + ): void { + this.off(type, listener); + } + + /** + * Request-response pattern: Publish a message and wait for a correlated response + * This enables synchronous-style communication over the async MessageBus + * The correlation ID is generated internally and added to the request + */ + async request( + request: Omit, + responseType: TResponse['type'], + timeoutMs: number = 60000, + ): Promise { + const correlationId = randomUUID(); + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + cleanup(); + reject(new Error(`Request timed out waiting for ${responseType}`)); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeoutId); + this.unsubscribe(responseType, responseHandler); + }; + + const responseHandler = (response: TResponse) => { + // Check if this response matches our request + if ( + 'correlationId' in response && + response.correlationId === correlationId + ) { + cleanup(); + resolve(response); + } + }; + + // Subscribe to responses + this.subscribe(responseType, responseHandler); + + // Publish the request with correlation ID + this.publish({ ...request, correlationId } as TRequest); + }); + } +} diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts new file mode 100644 index 0000000000..7a699bacb3 --- /dev/null +++ b/packages/core/src/confirmation-bus/types.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type FunctionCall } from '@google/genai'; +import type { + ToolConfirmationOutcome, + ToolConfirmationPayload, +} from '../tools/tools.js'; + +export enum MessageBusType { + TOOL_CONFIRMATION_REQUEST = 'tool-confirmation-request', + TOOL_CONFIRMATION_RESPONSE = 'tool-confirmation-response', + TOOL_EXECUTION_SUCCESS = 'tool-execution-success', + TOOL_EXECUTION_FAILURE = 'tool-execution-failure', + HOOK_EXECUTION_REQUEST = 'hook-execution-request', + HOOK_EXECUTION_RESPONSE = 'hook-execution-response', +} + +export interface ToolConfirmationRequest { + type: MessageBusType.TOOL_CONFIRMATION_REQUEST; + toolCall: FunctionCall; + correlationId: string; + serverName?: string; + /** + * Optional rich details for the confirmation UI (diffs, counts, etc.) + */ + details?: SerializableConfirmationDetails; +} + +export interface ToolConfirmationResponse { + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE; + correlationId: string; + confirmed: boolean; + /** + * The specific outcome selected by the user. + * + * TODO: Make required after migration. + */ + outcome?: ToolConfirmationOutcome; + /** + * Optional payload (e.g., modified content for 'modify_with_editor'). + */ + payload?: ToolConfirmationPayload; + /** + * When true, indicates that policy decision was ASK_USER and the tool should + * show its legacy confirmation UI instead of auto-proceeding. + */ + requiresUserConfirmation?: boolean; +} + +/** + * Data-only versions of ToolCallConfirmationDetails for bus transmission. + */ +export type SerializableConfirmationDetails = + | { + type: 'info'; + title: string; + prompt: string; + urls?: string[]; + } + | { + type: 'edit'; + title: string; + fileName: string; + filePath: string; + fileDiff: string; + originalContent: string | null; + newContent: string; + isModifying?: boolean; + } + | { + type: 'exec'; + title: string; + command: string; + rootCommand: string; + rootCommands: string[]; + commands?: string[]; + } + | { + type: 'mcp'; + title: string; + serverName: string; + toolName: string; + toolDisplayName: string; + } + | { + type: 'exit_plan_mode'; + title: string; + planPath: string; + }; + +export interface ToolExecutionSuccess { + type: MessageBusType.TOOL_EXECUTION_SUCCESS; + toolCall: FunctionCall; + result: T; +} + +export interface ToolExecutionFailure { + type: MessageBusType.TOOL_EXECUTION_FAILURE; + toolCall: FunctionCall; + error: E; +} + +export interface HookExecutionRequest { + type: MessageBusType.HOOK_EXECUTION_REQUEST; + eventName: string; + input: Record; + correlationId: string; +} + +export interface HookExecutionResponse { + type: MessageBusType.HOOK_EXECUTION_RESPONSE; + correlationId: string; + success: boolean; + output?: Record; + error?: Error; +} + +export type Message = + | ToolConfirmationRequest + | ToolConfirmationResponse + | ToolExecutionSuccess + | ToolExecutionFailure + | HookExecutionRequest + | HookExecutionResponse; diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index b5234045ed..1f0155ac17 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -356,6 +356,8 @@ describe('Gemini Client (client.ts)', () => { getSkipLoopDetection: vi.fn().mockReturnValue(false), getChatRecordingService: vi.fn().mockReturnValue(undefined), getResumedSessionData: vi.fn().mockReturnValue(undefined), + getEnableHooks: vi.fn().mockReturnValue(false), + getMessageBus: vi.fn().mockReturnValue(undefined), } as unknown as Config; client = new GeminiClient(mockConfig); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 9f3625c381..03a91b0d2c 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -69,9 +69,19 @@ import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; import { flatMapTextParts } from '../utils/partUtils.js'; import { retryWithBackoff } from '../utils/retry.js'; +// Hook types and utilities +import { + MessageBusType, + type HookExecutionRequest, + type HookExecutionResponse, +} from '../confirmation-bus/types.js'; +import { partToString } from '../utils/partUtils.js'; +import { createHookOutput } from '../hooks/types.js'; + // IDE integration import { ideContextStore } from '../ide/ideContext.js'; import { type File, type IdeContext } from '../ide/types.js'; +import type { StopHookOutput } from '../hooks/types.js'; const MAX_TURNS = 100; @@ -407,6 +417,51 @@ export class GeminiClient { options?: { isContinuation: boolean }, turns: number = MAX_TURNS, ): AsyncGenerator { + // Fire UserPromptSubmit hook through MessageBus (only if hooks are enabled) + const hooksEnabled = this.config.getEnableHooks(); + const messageBus = this.config.getMessageBus(); + if (hooksEnabled && messageBus) { + const promptText = partToString(request); + const response = await messageBus.request< + HookExecutionRequest, + HookExecutionResponse + >( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'UserPromptSubmit', + input: { + prompt: promptText, + }, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + ); + const hookOutput = response.output + ? createHookOutput('UserPromptSubmit', response.output) + : undefined; + + if ( + hookOutput?.isBlockingDecision() || + hookOutput?.shouldStopExecution() + ) { + yield { + type: GeminiEventType.Error, + value: { + error: new Error( + `UserPromptSubmit hook blocked processing: ${hookOutput.getEffectiveReason()}`, + ), + }, + }; + return new Turn(this.getChat(), prompt_id); + } + + // Add additional context from hooks to the request + const additionalContext = hookOutput?.getAdditionalContext(); + if (additionalContext) { + const requestArray = Array.isArray(request) ? request : [request]; + request = [...requestArray, { text: additionalContext }]; + } + } + if (!options?.isContinuation) { this.loopDetector.reset(prompt_id); this.lastPromptId = prompt_id; @@ -536,6 +591,65 @@ export class GeminiClient { return turn; } } + // Fire Stop hook through MessageBus (only if hooks are enabled) + // This must be done before any early returns to ensure hooks are always triggered + if (hooksEnabled && messageBus && !turn.pendingToolCalls.length) { + // Get response text from the chat history + const history = this.getHistory(); + const lastModelMessage = history + .filter((msg) => msg.role === 'model') + .pop(); + const responseText = + lastModelMessage?.parts + ?.filter((p): p is { text: string } => 'text' in p) + .map((p) => p.text) + .join('') || '[no response text]'; + + const response = await messageBus.request< + HookExecutionRequest, + HookExecutionResponse + >( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'Stop', + input: { + stop_hook_active: true, + last_assistant_message: responseText, + }, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + ); + const hookOutput = response.output + ? createHookOutput('Stop', response.output) + : undefined; + + const stopOutput = hookOutput as StopHookOutput | undefined; + + // For Stop hooks, blocking/stop execution should force continuation + if ( + stopOutput?.isBlockingDecision() || + stopOutput?.shouldStopExecution() + ) { + // Emit system message if provided (e.g., "🔄 Ralph iteration 5") + if (stopOutput.systemMessage) { + yield { + type: GeminiEventType.HookSystemMessage, + value: stopOutput.systemMessage, + }; + } + + const continueReason = stopOutput.getEffectiveReason(); + const continueRequest = [{ text: continueReason }]; + return yield* this.sendMessageStream( + continueRequest, + signal, + prompt_id, + { isContinuation: true }, + boundedTurns - 1, + ); + } + } + if (!turn.pendingToolCalls.length && signal && !signal.aborted) { if (this.config.getSkipNextSpeakerCheck()) { return turn; @@ -557,9 +671,9 @@ export class GeminiClient { ); if (nextSpeakerCheck?.next_speaker === 'model') { const nextRequest = [{ text: 'Please continue.' }]; - // This recursive call's events will be yielded out, but the final - // turn object will be from the top-level call. - yield* this.sendMessageStream( + // This recursive call's events will be yielded out, and the final + // turn object from the recursive call will be returned. + return yield* this.sendMessageStream( nextRequest, signal, prompt_id, @@ -568,6 +682,7 @@ export class GeminiClient { ); } } + return turn; } diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts index dc4d696d5e..1735097bee 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts @@ -813,7 +813,12 @@ describe('StreamingToolCallParser', () => { it('should return true when a tool call is inside a string literal', () => { // Simulate truncation mid-string: {"file_path": "/tmp/test.txt", "content": "some text - parser.addChunk(0, '{"file_path": "/tmp/test.txt"', 'call_1', 'write_file'); + parser.addChunk( + 0, + '{"file_path": "/tmp/test.txt"', + 'call_1', + 'write_file', + ); parser.addChunk(0, ', "content": "some text'); const state = parser.getState(0); expect(state.inString).toBe(true); diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 99eb983def..08f379d688 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -64,6 +64,7 @@ export enum GeminiEventType { LoopDetected = 'loop_detected', Citation = 'citation', Retry = 'retry', + HookSystemMessage = 'hook_system_message', } export type ServerGeminiRetryEvent = { @@ -202,6 +203,11 @@ export type ServerGeminiCitationEvent = { value: string; }; +export type ServerGeminiHookSystemMessageEvent = { + type: GeminiEventType.HookSystemMessage; + value: string; +}; + // The original union type, now composed of the individual types export type ServerGeminiStreamEvent = | ServerGeminiChatCompressedEvent @@ -209,6 +215,7 @@ export type ServerGeminiStreamEvent = | ServerGeminiContentEvent | ServerGeminiErrorEvent | ServerGeminiFinishedEvent + | ServerGeminiHookSystemMessageEvent | ServerGeminiLoopDetectedEvent | ServerGeminiMaxSessionTurnsEvent | ServerGeminiThoughtEvent diff --git a/packages/core/src/hooks/hookAggregator.test.ts b/packages/core/src/hooks/hookAggregator.test.ts new file mode 100644 index 0000000000..129713b660 --- /dev/null +++ b/packages/core/src/hooks/hookAggregator.test.ts @@ -0,0 +1,618 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { HookAggregator } from './hookAggregator.js'; +import { HookEventName, HookType, createHookOutput } from './types.js'; +import type { + HookExecutionResult, + HookOutput, + PermissionRequestHookOutput, +} from './types.js'; + +describe('HookAggregator', () => { + const aggregator = new HookAggregator(); + + describe('aggregateResults', () => { + it('should return undefined finalOutput when no results', () => { + const result = aggregator.aggregateResults([], HookEventName.PreToolUse); + expect(result.success).toBe(true); + expect(result.finalOutput).toBeUndefined(); + expect(result.allOutputs).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it('should aggregate successful results', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output: { continue: true }, + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.success).toBe(true); + expect(result.finalOutput).toBeDefined(); + }); + + it('should set success false when there are errors', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: false, + error: new Error('Hook failed'), + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.success).toBe(false); + expect(result.errors).toHaveLength(1); + }); + + it('should calculate total duration', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo 1' }, + eventName: HookEventName.PreToolUse, + success: true, + duration: 100, + }, + { + hookConfig: { type: HookType.Command, command: 'echo 2' }, + eventName: HookEventName.PreToolUse, + success: true, + duration: 200, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.totalDuration).toBe(300); + }); + }); + + describe('mergeWithOrLogic - PreToolUse', () => { + it('should concatenate reasons', () => { + const outputs: HookOutput[] = [ + { reason: 'first reason', decision: 'allow' }, + { reason: 'second reason', decision: 'allow' }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.reason).toBe('first reason\nsecond reason'); + }); + + it('should block when any hook blocks', () => { + const outputs: HookOutput[] = [ + { reason: 'allowed', decision: 'allow' }, + { reason: 'blocked', decision: 'block' }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.decision).toBe('block'); + }); + + it('should use last stopReason', () => { + const outputs: HookOutput[] = [ + { continue: false, stopReason: 'first stop' }, + { continue: false, stopReason: 'second stop' }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.Stop, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults(results, HookEventName.Stop); + expect(result.finalOutput?.stopReason).toBe('second stop'); + }); + + it('should concatenate additionalContext', () => { + const outputs: HookOutput[] = [ + { hookSpecificOutput: { additionalContext: 'context 1' } }, + { hookSpecificOutput: { additionalContext: 'context 2' } }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect( + result.finalOutput?.hookSpecificOutput?.['additionalContext'], + ).toBe('context 1\ncontext 2'); + }); + + it('should preserve other hookSpecificOutput fields', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + additionalContext: 'ctx', + tailToolCallRequest: { name: 'A' }, + }, + }, + { hookSpecificOutput: { additionalContext: 'ctx2' } }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PostToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PostToolUse, + ); + expect( + result.finalOutput?.hookSpecificOutput?.['tailToolCallRequest'], + ).toEqual({ name: 'A' }); + expect( + result.finalOutput?.hookSpecificOutput?.['additionalContext'], + ).toBe('ctx\nctx2'); + }); + }); + + describe('mergePermissionRequestOutputs', () => { + it('should prioritize deny over allow', () => { + const outputs: HookOutput[] = [ + { hookSpecificOutput: { decision: { behavior: 'allow' } } }, + { hookSpecificOutput: { decision: { behavior: 'deny' } } }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + // Use accessor to verify - this ensures output is consumable by PermissionRequestHookOutput + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + expect(hookOutput.isPermissionDenied()).toBe(true); + }); + + it('should concatenate messages', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + decision: { message: 'msg1', behavior: 'allow' }, + }, + }, + { + hookSpecificOutput: { + decision: { message: 'msg2', behavior: 'allow' }, + }, + }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + expect(hookOutput.getDenyMessage()).toBe('msg1\nmsg2'); + }); + + it('should use last updatedInput', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + decision: { updatedInput: { arg: '1' }, behavior: 'allow' }, + }, + }, + { + hookSpecificOutput: { + decision: { updatedInput: { arg: '2' }, behavior: 'allow' }, + }, + }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + expect(hookOutput.getUpdatedToolInput()).toEqual({ arg: '2' }); + }); + + it('should concatenate updatedPermissions', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + decision: { + updatedPermissions: [{ type: 'read' }], + behavior: 'allow', + }, + }, + }, + { + hookSpecificOutput: { + decision: { + updatedPermissions: [{ type: 'write' }], + behavior: 'allow', + }, + }, + }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + expect(hookOutput.getUpdatedPermissions()).toEqual([ + { type: 'read' }, + { type: 'write' }, + ]); + }); + + it('should set interrupt true if any hook sets it', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + decision: { behavior: 'deny', interrupt: false }, + }, + }, + { + hookSpecificOutput: { + decision: { behavior: 'deny', interrupt: true }, + }, + }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + expect(hookOutput.shouldInterrupt()).toBe(true); + }); + + it('should produce output consumable by PermissionRequestHookOutput accessors', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + decision: { + behavior: 'allow', + message: 'first msg', + updatedInput: { arg: '1' }, + }, + }, + }, + { + hookSpecificOutput: { + decision: { + behavior: 'deny', + message: 'second msg', + updatedInput: { arg: '2' }, + }, + }, + }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + // Verify the output can be consumed by PermissionRequestHookOutput accessors + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + + expect(hookOutput.isPermissionDenied()).toBe(true); + expect(hookOutput.getUpdatedToolInput()).toEqual({ arg: '2' }); + expect(hookOutput.getDenyMessage()).toBe('first msg\nsecond msg'); + }); + }); + + describe('mergeSimple (default case)', () => { + it('should use later values for simple fields', () => { + const outputs: HookOutput[] = [ + { reason: 'first', continue: true }, + { reason: 'second', continue: false }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.Notification, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.Notification, + ); + expect(result.finalOutput?.reason).toBe('second'); + expect(result.finalOutput?.continue).toBe(false); + }); + + it('should concatenate additionalContext from multiple hooks', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + additionalContext: 'ctx1', + otherField: 'value1', + }, + }, + { hookSpecificOutput: { additionalContext: 'ctx2' } }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.Notification, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.Notification, + ); + // mergeSimple concatenates additionalContext with newlines + expect( + result.finalOutput?.hookSpecificOutput?.['additionalContext'], + ).toBe('ctx1\nctx2'); + // otherField is overwritten (later value wins since it's not special-cased) + expect( + result.finalOutput?.hookSpecificOutput?.['otherField'], + ).toBeUndefined(); + }); + }); + + describe('createSpecificHookOutput', () => { + it('should create PreToolUseHookOutput for PreToolUse', () => { + const output: HookOutput = { continue: true }; + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + // The finalOutput should be an instance of PreToolUseHookOutput + expect(result.finalOutput).toBeDefined(); + expect((result.finalOutput as { continue?: boolean }).continue).toBe( + true, + ); + }); + + it('should create StopHookOutput for Stop', () => { + const output: HookOutput = { stopReason: 'test' }; + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.Stop, + success: true, + output, + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults(results, HookEventName.Stop); + expect(result.finalOutput).toBeDefined(); + expect((result.finalOutput as { stopReason?: string }).stopReason).toBe( + 'test', + ); + }); + + it('should create PermissionRequestHookOutput for PermissionRequest', () => { + const output: HookOutput = { + hookSpecificOutput: { decision: { behavior: 'allow' } }, + }; + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + expect(result.finalOutput).toBeDefined(); + }); + }); + + describe('edge cases', () => { + it('should handle empty outputs array', () => { + const results: HookExecutionResult[] = []; + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput).toBeUndefined(); + }); + + it('should handle single output', () => { + const output: HookOutput = { decision: 'allow', reason: 'single' }; + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.decision).toBe('allow'); + expect(result.finalOutput?.reason).toBe('single'); + }); + + it('should handle outputs without hookSpecificOutput', () => { + const outputs: HookOutput[] = [{ decision: 'allow' }, { reason: 'test' }]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.decision).toBe('allow'); + expect(result.finalOutput?.reason).toBe('test'); + }); + + it('should handle decision allow when no block', () => { + const outputs: HookOutput[] = [ + { decision: 'allow' }, + { decision: 'allow' }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.decision).toBe('allow'); + }); + }); +}); diff --git a/packages/core/src/hooks/hookAggregator.ts b/packages/core/src/hooks/hookAggregator.ts new file mode 100644 index 0000000000..48af7a2a96 --- /dev/null +++ b/packages/core/src/hooks/hookAggregator.ts @@ -0,0 +1,368 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + HookEventName, + DefaultHookOutput, + PreToolUseHookOutput, + StopHookOutput, + PermissionRequestHookOutput, +} from './types.js'; +import type { HookOutput, HookExecutionResult } from './types.js'; + +/** + * Aggregated result from multiple hook executions + */ +export interface AggregatedHookResult { + success: boolean; + allOutputs: HookOutput[]; + errors: Error[]; + totalDuration: number; + finalOutput?: HookOutput; +} + +/** + * HookAggregator merges multiple hook outputs using event-specific rules. + * + * Different events have different merging strategies: + * - PreToolUse/PostToolUse: OR logic for decisions, concatenation for messages + */ +export class HookAggregator { + /** + * Aggregate results from multiple hook executions + */ + aggregateResults( + results: HookExecutionResult[], + eventName: HookEventName, + ): AggregatedHookResult { + const allOutputs: HookOutput[] = []; + const errors: Error[] = []; + let totalDuration = 0; + + for (const result of results) { + totalDuration += result.duration; + + if (!result.success && result.error) { + errors.push(result.error); + } + + if (result.output) { + allOutputs.push(result.output); + } + } + + const success = errors.length === 0; + const finalOutput = this.mergeOutputs(allOutputs, eventName); + + return { + success, + allOutputs, + errors, + totalDuration, + finalOutput, + }; + } + + /** + * Merge multiple hook outputs based on event type + */ + private mergeOutputs( + outputs: HookOutput[], + eventName: HookEventName, + ): HookOutput | undefined { + if (outputs.length === 0) { + return undefined; + } + + if (outputs.length === 1) { + return this.createSpecificHookOutput(outputs[0], eventName); + } + + let merged: HookOutput; + + switch (eventName) { + case HookEventName.PreToolUse: + case HookEventName.PostToolUse: + case HookEventName.PostToolUseFailure: + case HookEventName.Stop: + merged = this.mergeWithOrLogic(outputs); + break; + case HookEventName.PermissionRequest: + merged = this.mergePermissionRequestOutputs(outputs); + break; + default: + merged = this.mergeSimple(outputs); + } + + return this.createSpecificHookOutput(merged, eventName); + } + + /** + * Merge outputs using OR logic for decisions and concatenation for messages. + * + * Rules: + * - Any "block" or "deny" decision results in blocking (most restrictive wins) + * - Reasons are concatenated with newlines + * - continue=false takes precedence over continue=true + * - Additional context is concatenated + */ + private mergeWithOrLogic(outputs: HookOutput[]): HookOutput { + const merged: HookOutput = {}; + const reasons: string[] = []; + const additionalContexts: string[] = []; + let hasBlock = false; + let hasContinueFalse = false; + let stopReason: string | undefined; + const otherHookSpecificFields: Record = {}; + + for (const output of outputs) { + // Check for blocking decisions + if (output.decision === 'block' || output.decision === 'deny') { + hasBlock = true; + } + + // Collect reasons + if (output.reason) { + reasons.push(output.reason); + } + + // Check continue flag + if (output.continue === false) { + hasContinueFalse = true; + if (output.stopReason) { + stopReason = output.stopReason; + } + } + + // Extract additional context + this.extractAdditionalContext(output, additionalContexts); + + // Collect other hookSpecificOutput fields (later values win) + if (output.hookSpecificOutput) { + for (const [key, value] of Object.entries(output.hookSpecificOutput)) { + if (key !== 'additionalContext') { + otherHookSpecificFields[key] = value; + } + } + } + + // Copy other fields (later values win for simple fields) + if (output.suppressOutput !== undefined) { + merged.suppressOutput = output.suppressOutput; + } + if (output.systemMessage !== undefined) { + merged.systemMessage = output.systemMessage; + } + } + + // Set merged decision + if (hasBlock) { + merged.decision = 'block'; + } else if (outputs.some((o) => o.decision === 'allow')) { + merged.decision = 'allow'; + } + + // Set merged reason + if (reasons.length > 0) { + merged.reason = reasons.join('\n'); + } + + // Set continue flag + if (hasContinueFalse) { + merged.continue = false; + if (stopReason) { + merged.stopReason = stopReason; + } + } + + // Build hookSpecificOutput + const hookSpecificOutput: Record = { + ...otherHookSpecificFields, + }; + if (additionalContexts.length > 0) { + hookSpecificOutput['additionalContext'] = additionalContexts.join('\n'); + } + + if (Object.keys(hookSpecificOutput).length > 0) { + merged.hookSpecificOutput = hookSpecificOutput; + } + + return merged; + } + + /** + * Merge outputs for mergePermissionRequestOutputs events. + * + * Rules: + * - behavior: deny wins over allow (security priority) + * - message: concatenated with newlines + * - updatedInput: later values win + * - updatedPermissions: concatenated + * - interrupt: true wins over false + */ + private mergePermissionRequestOutputs(outputs: HookOutput[]): HookOutput { + const merged: HookOutput = {}; + const messages: string[] = []; + let hasDeny = false; + let hasAllow = false; + let interrupt = false; + let updatedInput: Record | undefined; + const allUpdatedPermissions: Array<{ type: string; tool?: string }> = []; + + for (const output of outputs) { + const specific = output.hookSpecificOutput; + if (!specific) continue; + + const decision = specific['decision'] as + | { + behavior?: string; + message?: string; + updatedInput?: Record; + updatedPermissions?: Array<{ type: string; tool?: string }>; + interrupt?: boolean; + } + | undefined; + + if (!decision) continue; + + // Check behavior + if (decision['behavior'] === 'deny') { + hasDeny = true; + } else if (decision['behavior'] === 'allow') { + hasAllow = true; + } + + // Collect message + if (decision['message']) { + messages.push(decision['message'] as string); + } + + // Check interrupt - true wins + if (decision['interrupt'] === true) { + interrupt = true; + } + + // Collect updatedInput - use last non-empty + if (decision['updatedInput']) { + updatedInput = decision['updatedInput'] as Record; + } + + // Collect updatedPermissions + if (decision['updatedPermissions']) { + allUpdatedPermissions.push( + ...(decision['updatedPermissions'] as Array<{ + type: string; + tool?: string; + }>), + ); + } + + // Copy other fields + if (output.continue !== undefined) { + merged.continue = output.continue; + } + if (output.reason !== undefined) { + merged.reason = output.reason; + } + } + + // Build merged decision + const mergedDecision: Record = {}; + + if (hasDeny) { + mergedDecision['behavior'] = 'deny'; + } else if (hasAllow) { + mergedDecision['behavior'] = 'allow'; + } + + if (messages.length > 0) { + mergedDecision['message'] = messages.join('\n'); + } + + if (interrupt) { + mergedDecision['interrupt'] = true; + } + + if (updatedInput) { + mergedDecision['updatedInput'] = updatedInput; + } + + if (allUpdatedPermissions.length > 0) { + mergedDecision['updatedPermissions'] = allUpdatedPermissions; + } + + merged.hookSpecificOutput = { + ...merged.hookSpecificOutput, + decision: mergedDecision, + }; + + return merged; + } + + /** + * Simple merge for events without special logic + */ + private mergeSimple(outputs: HookOutput[]): HookOutput { + const additionalContexts: string[] = []; + let merged: HookOutput = {}; + + for (const output of outputs) { + // Collect additionalContext for concatenation + this.extractAdditionalContext(output, additionalContexts); + merged = { ...merged, ...output }; + } + + // Merge additionalContext with concatenation + if (additionalContexts.length > 0) { + merged.hookSpecificOutput = { + ...merged.hookSpecificOutput, + additionalContext: additionalContexts.join('\n'), + }; + } + + return merged; + } + + /** + * Create the appropriate specific hook output class based on event type + */ + private createSpecificHookOutput( + output: HookOutput, + eventName: HookEventName, + ): DefaultHookOutput { + switch (eventName) { + case HookEventName.PreToolUse: + return new PreToolUseHookOutput(output); + case HookEventName.Stop: + return new StopHookOutput(output); + case HookEventName.PermissionRequest: + return new PermissionRequestHookOutput(output); + default: + return new DefaultHookOutput(output); + } + } + + /** + * Extract additional context from hook-specific outputs + */ + private extractAdditionalContext( + output: HookOutput, + contexts: string[], + ): void { + const specific = output.hookSpecificOutput; + if (!specific) { + return; + } + + // Extract additionalContext from various hook types + if ( + 'additionalContext' in specific && + typeof specific['additionalContext'] === 'string' + ) { + contexts.push(specific['additionalContext']); + } + } +} diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts new file mode 100644 index 0000000000..f556a8c30a --- /dev/null +++ b/packages/core/src/hooks/hookEventHandler.test.ts @@ -0,0 +1,278 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { HookEventHandler } from './hookEventHandler.js'; +import { HookEventName, HookType, HooksConfigSource } from './types.js'; +import type { Config } from '../config/config.js'; +import type { + HookPlanner, + HookRunner, + HookAggregator, + AggregatedHookResult, +} from './index.js'; +import type { HookConfig, HookOutput } from './types.js'; + +describe('HookEventHandler', () => { + let mockConfig: Config; + let mockHookPlanner: HookPlanner; + let mockHookRunner: HookRunner; + let mockHookAggregator: HookAggregator; + let hookEventHandler: HookEventHandler; + + beforeEach(() => { + mockConfig = { + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'), + getWorkingDir: vi.fn().mockReturnValue('/test/cwd'), + } as unknown as Config; + + mockHookPlanner = { + createExecutionPlan: vi.fn(), + } as unknown as HookPlanner; + + mockHookRunner = { + executeHooksSequential: vi.fn(), + executeHooksParallel: vi.fn(), + } as unknown as HookRunner; + + mockHookAggregator = { + aggregateResults: vi.fn(), + } as unknown as HookAggregator; + + hookEventHandler = new HookEventHandler( + mockConfig, + mockHookPlanner, + mockHookRunner, + mockHookAggregator, + ); + }); + + const createMockExecutionPlan = ( + hookConfigs: HookConfig[] = [], + sequential: boolean = false, + ) => ({ + hookConfigs, + sequential, + eventName: HookEventName.PreToolUse, + }); + + const createMockAggregatedResult = ( + success: boolean = true, + finalOutput?: HookOutput, + ): AggregatedHookResult => ({ + success, + allOutputs: [], + errors: [], + totalDuration: 100, + finalOutput, + }); + + describe('fireUserPromptSubmitEvent', () => { + it('should execute hooks for UserPromptSubmit event', async () => { + const mockPlan = createMockExecutionPlan([]); + const mockAggregated = createMockAggregatedResult(true); + + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + mockAggregated, + ); + + const result = + await hookEventHandler.fireUserPromptSubmitEvent('test prompt'); + + expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith( + HookEventName.UserPromptSubmit, + undefined, + ); + expect(result.success).toBe(true); + }); + + it('should include prompt in the hook input', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireUserPromptSubmitEvent('my test prompt'); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { prompt: string }; + expect(input.prompt).toBe('my test prompt'); + }); + }); + + describe('fireStopEvent', () => { + it('should execute hooks for Stop event', async () => { + const mockPlan = createMockExecutionPlan([]); + const mockAggregated = createMockAggregatedResult(true); + + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + mockAggregated, + ); + + const result = await hookEventHandler.fireStopEvent(true, 'last message'); + + expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith( + HookEventName.Stop, + undefined, + ); + expect(result.success).toBe(true); + }); + + it('should include stop parameters in hook input', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireStopEvent(true, 'last assistant message'); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { + stop_hook_active: boolean; + last_assistant_message: string; + }; + expect(input.stop_hook_active).toBe(true); + expect(input.last_assistant_message).toBe('last assistant message'); + }); + + it('should handle continue=false in final output', async () => { + const mockPlan = createMockExecutionPlan([]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true, { + continue: false, + stopReason: 'test stop', + }), + ); + + await hookEventHandler.fireStopEvent(); + + expect(true).toBe(true); + }); + + it('should handle missing finalOutput gracefully', async () => { + const mockPlan = createMockExecutionPlan([]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true, undefined), + ); + + const result = await hookEventHandler.fireStopEvent(); + + expect(result.success).toBe(true); + expect(result.finalOutput).toBeUndefined(); + }); + }); + + describe('sequential vs parallel execution', () => { + it('should execute hooks sequentially when plan.sequential is true', async () => { + const mockPlan = createMockExecutionPlan( + [ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ], + true, + ); + + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksSequential).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireUserPromptSubmitEvent('test'); + + expect(mockHookRunner.executeHooksSequential).toHaveBeenCalled(); + expect(mockHookRunner.executeHooksParallel).not.toHaveBeenCalled(); + }); + + it('should execute hooks in parallel when plan.sequential is false', async () => { + const mockPlan = createMockExecutionPlan( + [ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ], + false, + ); + + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireUserPromptSubmitEvent('test'); + + expect(mockHookRunner.executeHooksParallel).toHaveBeenCalled(); + expect(mockHookRunner.executeHooksSequential).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should return error result when hook execution throws', async () => { + vi.mocked(mockHookPlanner.createExecutionPlan).mockImplementation(() => { + throw new Error('Planner error'); + }); + + const result = await hookEventHandler.fireUserPromptSubmitEvent('test'); + + expect(result.success).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Planner error'); + }); + + it('should return error result when hook runner throws', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockRejectedValue( + new Error('Runner error'), + ); + + const result = await hookEventHandler.fireUserPromptSubmitEvent('test'); + + expect(result.success).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Runner error'); + }); + }); +}); diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts new file mode 100644 index 0000000000..2fd5f28920 --- /dev/null +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import type { HookPlanner, HookEventContext } from './hookPlanner.js'; +import type { HookRunner } from './hookRunner.js'; +import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js'; +import { HookEventName } from './types.js'; +import type { + HookConfig, + HookInput, + HookExecutionResult, + UserPromptSubmitInput, + StopInput, +} from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +/** + * Hook event bus that coordinates hook execution across the system + */ +export class HookEventHandler { + private readonly config: Config; + private readonly hookPlanner: HookPlanner; + private readonly hookRunner: HookRunner; + private readonly hookAggregator: HookAggregator; + + constructor( + config: Config, + hookPlanner: HookPlanner, + hookRunner: HookRunner, + hookAggregator: HookAggregator, + ) { + this.config = config; + this.hookPlanner = hookPlanner; + this.hookRunner = hookRunner; + this.hookAggregator = hookAggregator; + } + + /** + * Fire a UserPromptSubmit event + * Called by handleHookExecutionRequest - executes hooks directly + */ + async fireUserPromptSubmitEvent( + prompt: string, + ): Promise { + const input: UserPromptSubmitInput = { + ...this.createBaseInput(HookEventName.UserPromptSubmit), + prompt, + }; + + return this.executeHooks(HookEventName.UserPromptSubmit, input); + } + + /** + * Fire a Stop event + * Called by handleHookExecutionRequest - executes hooks directly + */ + async fireStopEvent( + stopHookActive: boolean = false, + lastAssistantMessage: string = '', + ): Promise { + const input: StopInput = { + ...this.createBaseInput(HookEventName.Stop), + stop_hook_active: stopHookActive, + last_assistant_message: lastAssistantMessage, + }; + + return this.executeHooks(HookEventName.Stop, input); + } + + /** + * Execute hooks for a specific event (direct execution without MessageBus) + * Used as fallback when MessageBus is not available + */ + private async executeHooks( + eventName: HookEventName, + input: HookInput, + context?: HookEventContext, + ): Promise { + try { + // Create execution plan + const plan = this.hookPlanner.createExecutionPlan(eventName, context); + + if (!plan || plan.hookConfigs.length === 0) { + return { + success: true, + allOutputs: [], + errors: [], + totalDuration: 0, + }; + } + + const onHookStart = (_config: HookConfig, _index: number) => { + // Hook start event (telemetry removed) + }; + + const onHookEnd = (_config: HookConfig, _result: HookExecutionResult) => { + // Hook end event (telemetry removed) + }; + + // Execute hooks according to the plan's strategy + const results = plan.sequential + ? await this.hookRunner.executeHooksSequential( + plan.hookConfigs, + eventName, + input, + onHookStart, + onHookEnd, + ) + : await this.hookRunner.executeHooksParallel( + plan.hookConfigs, + eventName, + input, + onHookStart, + onHookEnd, + ); + + // Aggregate results + const aggregated = this.hookAggregator.aggregateResults( + results, + eventName, + ); + + // Process common hook output fields centrally + this.processCommonHookOutputFields(aggregated); + + return aggregated; + } catch (error) { + debugLogger.error(`Hook event bus error for ${eventName}: ${error}`); + + return { + success: false, + allOutputs: [], + errors: [error instanceof Error ? error : new Error(String(error))], + totalDuration: 0, + }; + } + } + + /** + * Create base hook input with common fields + */ + private createBaseInput(eventName: HookEventName): HookInput { + // Get the transcript path from the Config + const transcriptPath = this.config.getTranscriptPath(); + + return { + session_id: this.config.getSessionId(), + transcript_path: transcriptPath, + cwd: this.config.getWorkingDir(), + hook_event_name: eventName, + timestamp: new Date().toISOString(), + }; + } + + /** + * Process common hook output fields centrally + */ + private processCommonHookOutputFields( + aggregated: AggregatedHookResult, + ): void { + if (!aggregated.finalOutput) { + return; + } + + // Handle systemMessage - show to user in transcript mode (not to agent) + const systemMessage = aggregated.finalOutput.systemMessage; + if (systemMessage && !aggregated.finalOutput.suppressOutput) { + debugLogger.warn(`Hook system message: ${systemMessage}`); + } + + // Handle suppressOutput - already handled by not logging above when true + + // Handle continue=false - this should stop the entire agent execution + if (aggregated.finalOutput.continue === false) { + const stopReason = + aggregated.finalOutput.stopReason || + aggregated.finalOutput.reason || + 'No reason provided'; + debugLogger.debug(`Hook requested to stop execution: ${stopReason}`); + + // Note: The actual stopping of execution must be handled by integration points + // as they need to interpret this signal in the context of their specific workflow + // This is just logging the request centrally + } + } +} diff --git a/packages/core/src/hooks/hookPlanner.test.ts b/packages/core/src/hooks/hookPlanner.test.ts new file mode 100644 index 0000000000..e3bb990763 --- /dev/null +++ b/packages/core/src/hooks/hookPlanner.test.ts @@ -0,0 +1,366 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js'; +import { HookPlanner } from './hookPlanner.js'; +import { HookEventName, HookType, HooksConfigSource } from './types.js'; + +describe('HookPlanner', () => { + let mockRegistry: HookRegistry; + let planner: HookPlanner; + + beforeEach(() => { + mockRegistry = { + getHooksForEvent: vi.fn(), + } as unknown as HookRegistry; + planner = new HookPlanner(mockRegistry); + }); + + describe('createExecutionPlan', () => { + it('should return null when no hooks for event', () => { + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse); + + expect(result).toBeNull(); + }); + + it('should return null when no hooks match context', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'bash', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'glob', + }); + + expect(result).toBeNull(); + }); + + it('should create plan with matching hooks', () => { + const entry: HookRegistryEntry = { + config: { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse); + + expect(result).not.toBeNull(); + expect(result!.eventName).toBe(HookEventName.PreToolUse); + expect(result!.hookConfigs).toHaveLength(1); + expect(result!.sequential).toBe(false); + }); + + it('should set sequential to true when any hook has sequential=true', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + sequential: true, + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse); + + expect(result!.sequential).toBe(true); + }); + + it('should deduplicate hooks with same config', () => { + const config = { type: HookType.Command, command: 'echo test' }; + const entry1: HookRegistryEntry = { + config, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + enabled: true, + }; + const entry2: HookRegistryEntry = { + config, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([ + entry1, + entry2, + ]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse); + + expect(result!.hookConfigs).toHaveLength(1); + }); + }); + + describe('matchesContext', () => { + it('should match all when no matcher', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should match all when no context', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'bash', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse); + + expect(result).not.toBeNull(); + }); + + it('should match empty string as wildcard', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should match asterisk as wildcard', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '*', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should match tool name with exact string', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'bash', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should not match tool name with different exact string', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'bash', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'glob', + }); + + expect(result).toBeNull(); + }); + + it('should match tool name with regex', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '^bash.*', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should match tool name with regex wildcard', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '.*', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'any-tool', + }); + + expect(result).not.toBeNull(); + }); + + it('should match trigger with exact string', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.SessionStart, + matcher: 'user', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.SessionStart, { + trigger: 'user', + }); + + expect(result).not.toBeNull(); + }); + + it('should not match trigger with different string', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.SessionStart, + matcher: 'user', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.SessionStart, { + trigger: 'api', + }); + + expect(result).toBeNull(); + }); + + it('should match when context has both toolName and trigger (prefers toolName)', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'bash', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + trigger: 'api', + }); + + expect(result).not.toBeNull(); + }); + + it('should match with trimmed matcher', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: ' bash ', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should fallback to exact match when regex is invalid', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '[invalid(regex', // Invalid regex + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + // Should fallback to exact match - should NOT match 'bash' + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).toBeNull(); + }); + + it('should match using fallback exact match when regex is invalid', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '[invalid(regex', // Invalid regex + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + // Should fallback to exact match - should match '[invalid(regex' + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: '[invalid(regex', + }); + + expect(result).not.toBeNull(); + }); + + it('should handle complex invalid regex gracefully', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '(unclosed', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/core/src/hooks/hookPlanner.ts b/packages/core/src/hooks/hookPlanner.ts new file mode 100644 index 0000000000..3eef015435 --- /dev/null +++ b/packages/core/src/hooks/hookPlanner.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js'; +import type { HookExecutionPlan } from './types.js'; +import { getHookKey, type HookEventName } from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +/** + * Hook planner that selects matching hooks and creates execution plans + */ +export class HookPlanner { + private readonly hookRegistry: HookRegistry; + + constructor(hookRegistry: HookRegistry) { + this.hookRegistry = hookRegistry; + } + + /** + * Create execution plan for a hook event + */ + createExecutionPlan( + eventName: HookEventName, + context?: HookEventContext, + ): HookExecutionPlan | null { + const hookEntries = this.hookRegistry.getHooksForEvent(eventName); + + if (hookEntries.length === 0) { + return null; + } + + // Filter hooks by matcher + const matchingEntries = hookEntries.filter((entry) => + this.matchesContext(entry, context), + ); + + if (matchingEntries.length === 0) { + return null; + } + + // Deduplicate identical hooks + const deduplicatedEntries = this.deduplicateHooks(matchingEntries); + + // Extract hook configs + const hookConfigs = deduplicatedEntries.map((entry) => entry.config); + + // Determine execution strategy - if ANY hook definition has sequential=true, run all sequentially + const sequential = deduplicatedEntries.some( + (entry) => entry.sequential === true, + ); + + const plan: HookExecutionPlan = { + eventName, + hookConfigs, + sequential, + }; + + return plan; + } + + /** + * Check if a hook entry matches the given context + */ + private matchesContext( + entry: HookRegistryEntry, + context?: HookEventContext, + ): boolean { + if (!entry.matcher || !context) { + return true; // No matcher means match all + } + + const matcher = entry.matcher.trim(); + + if (matcher === '' || matcher === '*') { + return true; // Empty string or wildcard matches all + } + + // For tool events, match against tool name + if (context.toolName) { + return this.matchesToolName(matcher, context.toolName); + } + + // For other events, match against trigger/source + if (context.trigger) { + return this.matchesTrigger(matcher, context.trigger); + } + + return true; + } + + /** + * Match tool name against matcher pattern + */ + private matchesToolName(matcher: string, toolName: string): boolean { + try { + // Attempt to treat the matcher as a regular expression. + const regex = new RegExp(matcher); + return regex.test(toolName); + } catch (error) { + // If it's not a valid regex, treat it as a literal string for an exact match. + debugLogger.warn( + `Invalid regex in hook matcher "${matcher}" for tool "${toolName}", falling back to exact match: ${error}`, + ); + return matcher === toolName; + } + } + + /** + * Match trigger/source against matcher pattern + */ + private matchesTrigger(matcher: string, trigger: string): boolean { + return matcher === trigger; + } + + /** + * Deduplicate identical hook configurations + */ + private deduplicateHooks(entries: HookRegistryEntry[]): HookRegistryEntry[] { + const seen = new Set(); + const deduplicated: HookRegistryEntry[] = []; + + for (const entry of entries) { + const key = getHookKey(entry.config); + + if (!seen.has(key)) { + seen.add(key); + deduplicated.push(entry); + } + } + + return deduplicated; + } +} + +/** + * Context information for hook event matching + */ +export interface HookEventContext { + toolName?: string; + trigger?: string; +} diff --git a/packages/core/src/hooks/hookRegistry.test.ts b/packages/core/src/hooks/hookRegistry.test.ts new file mode 100644 index 0000000000..a9e79f5fa6 --- /dev/null +++ b/packages/core/src/hooks/hookRegistry.test.ts @@ -0,0 +1,636 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { HookRegistryConfig, FeedbackEmitter } from './hookRegistry.js'; +import { HookRegistry } from './hookRegistry.js'; +import { HookEventName, HooksConfigSource, HookType } from './types.js'; +import type { HookConfig } from './types.js'; + +// Mock TrustedHooksManager +vi.mock('./trustedHooks.js', () => ({ + TrustedHooksManager: vi.fn().mockImplementation(() => ({ + getUntrustedHooks: vi.fn().mockReturnValue([]), + trustHooks: vi.fn(), + })), +})); + +describe('HookRegistry', () => { + let mockConfig: HookRegistryConfig; + let mockFeedbackEmitter: FeedbackEmitter; + + beforeEach(() => { + mockConfig = { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + isTrustedFolder: vi.fn().mockReturnValue(true), + getHooks: vi.fn().mockReturnValue(undefined), + getProjectHooks: vi.fn().mockReturnValue(undefined), + getDisabledHooks: vi.fn().mockReturnValue([]), + getExtensions: vi.fn().mockReturnValue([]), + }; + mockFeedbackEmitter = { + emitFeedback: vi.fn(), + }; + vi.clearAllMocks(); + }); + + describe('initialize', () => { + it('should initialize with empty hooks when no config provided', async () => { + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + expect(registry.getAllHooks()).toHaveLength(0); + }); + + it('should process project hooks from config', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const allHooks = registry.getAllHooks(); + expect(allHooks).toHaveLength(1); + expect(allHooks[0].eventName).toBe(HookEventName.PreToolUse); + expect(allHooks[0].source).toBe(HooksConfigSource.Project); + }); + + it('should not process project hooks in untrusted folder', async () => { + mockConfig.isTrustedFolder = vi.fn().mockReturnValue(false); + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [{ type: HookType.Command, command: 'echo test' }], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + }); + }); + + describe('getHooksForEvent', () => { + it('should return hooks for specific event', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { type: HookType.Command, command: 'echo pre', name: 'pre-hook' }, + ], + }, + ], + [HookEventName.PostToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo post', + name: 'post-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const preHooks = registry.getHooksForEvent(HookEventName.PreToolUse); + expect(preHooks).toHaveLength(1); + expect(preHooks[0].config.name).toBe('pre-hook'); + + const postHooks = registry.getHooksForEvent(HookEventName.PostToolUse); + expect(postHooks).toHaveLength(1); + expect(postHooks[0].config.name).toBe('post-hook'); + }); + + it('should filter out disabled hooks', async () => { + mockConfig.getDisabledHooks = vi.fn().mockReturnValue(['disabled-hook']); + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo enabled', + name: 'enabled-hook', + }, + { + type: HookType.Command, + command: 'echo disabled', + name: 'disabled-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks = registry.getHooksForEvent(HookEventName.PreToolUse); + expect(hooks).toHaveLength(1); + expect(hooks[0].config.name).toBe('enabled-hook'); + }); + + it('should sort hooks by source priority', async () => { + // This test requires multiple sources, which would need getUserHooks + // For now, we test with extensions which are processed after project hooks + const projectHooks = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo project', + name: 'project-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(projectHooks); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks = registry.getHooksForEvent(HookEventName.PreToolUse); + expect(hooks).toHaveLength(1); + expect(hooks[0].source).toBe(HooksConfigSource.Project); + }); + }); + + describe('setHookEnabled', () => { + it('should enable a disabled hook', async () => { + mockConfig.getDisabledHooks = vi.fn().mockReturnValue(['test-hook']); + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 0, + ); + + registry.setHookEnabled('test-hook', true); + + const hooks = registry.getHooksForEvent(HookEventName.PreToolUse); + expect(hooks).toHaveLength(1); + expect(hooks[0].enabled).toBe(true); + }); + + it('should disable an enabled hook', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 1, + ); + + registry.setHookEnabled('test-hook', false); + + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 0, + ); + }); + + it('should update all hooks with matching name', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { type: HookType.Command, command: 'echo 1', name: 'same-name' }, + ], + }, + ], + [HookEventName.PostToolUse]: [ + { + hooks: [ + { type: HookType.Command, command: 'echo 2', name: 'same-name' }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(2); + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 1, + ); + expect(registry.getHooksForEvent(HookEventName.PostToolUse)).toHaveLength( + 1, + ); + + registry.setHookEnabled('same-name', false); + + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 0, + ); + expect(registry.getHooksForEvent(HookEventName.PostToolUse)).toHaveLength( + 0, + ); + }); + }); + + describe('hook validation', () => { + it('should discard hooks with invalid type', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: 'invalid-type', + command: 'echo test', + } as unknown as HookConfig, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + }); + + it('should discard command hooks without command field', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [{ type: HookType.Command } as HookConfig], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + }); + + it('should skip invalid event names', async () => { + const hooksConfig = { + InvalidEventName: [ + { + hooks: [{ type: HookType.Command, command: 'echo test' }], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig, mockFeedbackEmitter); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + expect(mockFeedbackEmitter.emitFeedback).toHaveBeenCalledWith( + 'warning', + expect.stringContaining('Invalid hook event name'), + ); + }); + + it('should skip hooks config fields like enabled and disabled', async () => { + const hooksConfig = { + enabled: ['hook1'], + disabled: ['hook2'], + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'valid-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(1); + expect(registry.getAllHooks()[0].config.name).toBe('valid-hook'); + }); + }); + + describe('duplicate detection', () => { + it('should skip duplicate hooks with same name+source+event+matcher+sequential', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + matcher: '*.ts', + sequential: true, + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'dup-hook', + }, + { + type: HookType.Command, + command: 'echo test', + name: 'dup-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(1); + }); + + it('should allow hooks with same name but different matcher', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + matcher: '*.ts', + hooks: [ + { type: HookType.Command, command: 'echo ts', name: 'my-hook' }, + ], + }, + { + matcher: '*.js', + hooks: [ + { type: HookType.Command, command: 'echo js', name: 'my-hook' }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(2); + }); + + it('should allow hooks with same name but different sequential', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + sequential: true, + hooks: [ + { type: HookType.Command, command: 'echo seq', name: 'my-hook' }, + ], + }, + { + sequential: false, + hooks: [ + { type: HookType.Command, command: 'echo par', name: 'my-hook' }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(2); + }); + }); + + describe('extension hooks', () => { + it('should process hooks from active extensions', async () => { + const extensionHooks = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { type: HookType.Command, command: 'echo ext', name: 'ext-hook' }, + ], + }, + ], + }; + mockConfig.getExtensions = vi + .fn() + .mockReturnValue([{ isActive: true, hooks: extensionHooks }]); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const allHooks = registry.getAllHooks(); + expect(allHooks).toHaveLength(1); + expect(allHooks[0].source).toBe(HooksConfigSource.Extensions); + expect(allHooks[0].config.name).toBe('ext-hook'); + }); + + it('should skip hooks from inactive extensions', async () => { + const extensionHooks = { + [HookEventName.PreToolUse]: [ + { + hooks: [{ type: HookType.Command, command: 'echo ext' }], + }, + ], + }; + mockConfig.getExtensions = vi + .fn() + .mockReturnValue([{ isActive: false, hooks: extensionHooks }]); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + }); + + it('should process multiple extensions', async () => { + mockConfig.getExtensions = vi.fn().mockReturnValue([ + { + isActive: true, + hooks: { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo ext1', + name: 'ext1-hook', + }, + ], + }, + ], + }, + }, + { + isActive: true, + hooks: { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo ext2', + name: 'ext2-hook', + }, + ], + }, + ], + }, + }, + ]); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(2); + }); + }); + + describe('hook metadata', () => { + it('should preserve matcher in registry entry', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + matcher: 'ReadFileTool', + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'matcher-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks = registry.getAllHooks(); + expect(hooks[0].matcher).toBe('ReadFileTool'); + }); + + it('should preserve sequential flag in registry entry', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + sequential: true, + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'seq-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks = registry.getAllHooks(); + expect(hooks[0].sequential).toBe(true); + }); + + it('should add source to hook config', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'source-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks = registry.getAllHooks(); + expect(hooks[0].config.source).toBe(HooksConfigSource.Project); + }); + }); + + describe('getAllHooks', () => { + it('should return a copy of entries array', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks1 = registry.getAllHooks(); + const hooks2 = registry.getAllHooks(); + + expect(hooks1).toEqual(hooks2); + expect(hooks1).not.toBe(hooks2); // Different array reference + }); + }); +}); diff --git a/packages/core/src/hooks/hookRegistry.ts b/packages/core/src/hooks/hookRegistry.ts new file mode 100644 index 0000000000..54251c4956 --- /dev/null +++ b/packages/core/src/hooks/hookRegistry.ts @@ -0,0 +1,353 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HookDefinition, HookConfig } from './types.js'; +import { + HookEventName, + HooksConfigSource, + HOOKS_CONFIG_FIELDS, +} from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { TrustedHooksManager } from './trustedHooks.js'; + +const debugLogger = createDebugLogger('HOOK_REGISTRY'); + +/** + * Extension with hooks support + */ +export interface ExtensionWithHooks { + isActive: boolean; + hooks?: { [K in HookEventName]?: HookDefinition[] }; +} + +/** + * Configuration interface for HookRegistry + * This abstracts the Config dependency to make the registry more flexible + */ +export interface HookRegistryConfig { + getProjectRoot(): string; + isTrustedFolder(): boolean; + getHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined; + getProjectHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined; + getDisabledHooks(): string[]; + getExtensions(): ExtensionWithHooks[]; +} + +/** + * Feedback emitter interface for warning/info messages + */ +export interface FeedbackEmitter { + emitFeedback(type: 'warning' | 'info' | 'error', message: string): void; +} + +/** + * Hook registry entry with source information + */ +export interface HookRegistryEntry { + config: HookConfig; + source: HooksConfigSource; + eventName: HookEventName; + matcher?: string; + sequential?: boolean; + enabled: boolean; +} + +/** + * Hook registry that loads and validates hook definitions from multiple sources + */ +export class HookRegistry { + private readonly config: HookRegistryConfig; + private readonly feedbackEmitter?: FeedbackEmitter; + private entries: HookRegistryEntry[] = []; + + constructor(config: HookRegistryConfig, feedbackEmitter?: FeedbackEmitter) { + this.config = config; + this.feedbackEmitter = feedbackEmitter; + } + + /** + * Initialize the registry by processing hooks from config + */ + async initialize(): Promise { + this.entries = []; + this.processHooksFromConfig(); + + debugLogger.debug( + `Hook registry initialized with ${this.entries.length} hook entries`, + ); + } + + /** + * Get all hook entries for a specific event + */ + getHooksForEvent(eventName: HookEventName): HookRegistryEntry[] { + return this.entries + .filter((entry) => entry.eventName === eventName && entry.enabled) + .sort( + (a, b) => + this.getSourcePriority(a.source) - this.getSourcePriority(b.source), + ); + } + + /** + * Get all registered hooks + */ + getAllHooks(): HookRegistryEntry[] { + return [...this.entries]; + } + + /** + * Enable or disable a specific hook + */ + setHookEnabled(hookName: string, enabled: boolean): void { + const updated = this.entries.filter((entry) => { + const name = this.getHookName(entry); + if (name === hookName) { + entry.enabled = enabled; + return true; + } + return false; + }); + + if (updated.length > 0) { + debugLogger.info( + `${enabled ? 'Enabled' : 'Disabled'} ${updated.length} hook(s) matching "${hookName}"`, + ); + } else { + debugLogger.warn(`No hooks found matching "${hookName}"`); + } + } + + /** + * Get hook name for identification and display purposes + */ + private getHookName( + entry: HookRegistryEntry | { config: HookConfig }, + ): string { + return entry.config.name || entry.config.command || 'unknown-command'; + } + + /** + * Check for untrusted project hooks and warn the user + */ + private checkProjectHooksTrust(): void { + const projectHooks = this.config.getProjectHooks(); + if (!projectHooks) return; + + try { + const trustedHooksManager = new TrustedHooksManager(); + const untrusted = trustedHooksManager.getUntrustedHooks( + this.config.getProjectRoot(), + projectHooks, + ); + + if (untrusted.length > 0) { + const message = `WARNING: The following project-level hooks have been detected in this workspace: +${untrusted.map((h: string) => ` - ${h}`).join('\n')} + +These hooks will be executed. If you did not configure these hooks or do not trust this project, +please review the project settings (.qwen/settings.json) and remove them.`; + this.feedbackEmitter?.emitFeedback('warning', message); + + // Trust them so we don't warn again + trustedHooksManager.trustHooks( + this.config.getProjectRoot(), + projectHooks, + ); + } + } catch { + debugLogger.warn('Failed to check project hooks trust'); + } + } + + /** + * Process hooks from the config that was already loaded by the CLI + */ + private processHooksFromConfig(): void { + if (this.config.isTrustedFolder()) { + this.checkProjectHooksTrust(); + } + + // Get hooks from the main config (this comes from the merged settings) + const configHooks = this.config.getHooks(); + if (configHooks) { + if (this.config.isTrustedFolder()) { + this.processHooksConfiguration(configHooks, HooksConfigSource.Project); + } else { + debugLogger.warn( + 'Project hooks disabled because the folder is not trusted.', + ); + } + } + + // Get hooks from extensions + const extensions = this.config.getExtensions() || []; + for (const extension of extensions) { + if (extension.isActive && extension.hooks) { + this.processHooksConfiguration( + extension.hooks, + HooksConfigSource.Extensions, + ); + } + } + } + + /** + * Process hooks configuration and add entries + */ + private processHooksConfiguration( + hooksConfig: { [K in HookEventName]?: HookDefinition[] }, + source: HooksConfigSource, + ): void { + for (const [eventName, definitions] of Object.entries(hooksConfig)) { + if (HOOKS_CONFIG_FIELDS.includes(eventName)) { + continue; + } + + if (!this.isValidEventName(eventName)) { + this.feedbackEmitter?.emitFeedback( + 'warning', + `Invalid hook event name: "${eventName}" from ${source} config. Skipping.`, + ); + continue; + } + + const typedEventName = eventName; + + if (!Array.isArray(definitions)) { + debugLogger.warn( + `Hook definitions for event "${eventName}" from source "${source}" is not an array. Skipping.`, + ); + continue; + } + + for (const definition of definitions) { + this.processHookDefinition(definition, typedEventName, source); + } + } + } + + /** + * Process a single hook definition + */ + private processHookDefinition( + definition: HookDefinition, + eventName: HookEventName, + source: HooksConfigSource, + ): void { + if ( + !definition || + typeof definition !== 'object' || + !Array.isArray(definition.hooks) + ) { + debugLogger.warn( + `Discarding invalid hook definition for ${eventName} from ${source}:`, + definition, + ); + return; + } + + // Get disabled hooks list from settings + const disabledHooks = this.config.getDisabledHooks(); + + for (const hookConfig of definition.hooks) { + if ( + hookConfig && + typeof hookConfig === 'object' && + this.validateHookConfig(hookConfig, eventName, source) + ) { + // Check if this hook is in the disabled list + const hookName = this.getHookName({ config: hookConfig }); + const isDisabled = disabledHooks.includes(hookName); + + // Check for duplicate hooks (same name+command+source+eventName+matcher+sequential) + const isDuplicate = this.entries.some( + (existing) => + existing.eventName === eventName && + existing.source === source && + this.getHookName(existing) === hookName && + existing.matcher === definition.matcher && + existing.sequential === definition.sequential, + ); + if (isDuplicate) { + debugLogger.debug( + `Skipping duplicate hook "${hookName}" for ${eventName} from ${source}`, + ); + continue; + } + + // Add source to hook config + hookConfig.source = source; + + this.entries.push({ + config: hookConfig, + source, + eventName, + matcher: definition.matcher, + sequential: definition.sequential, + enabled: !isDisabled, + }); + } else { + // Invalid hooks are logged and discarded here, they won't reach HookRunner + debugLogger.warn( + `Discarding invalid hook configuration for ${eventName} from ${source}:`, + hookConfig, + ); + } + } + } + + /** + * Validate a hook configuration + */ + private validateHookConfig( + config: HookConfig, + eventName: HookEventName, + source: HooksConfigSource, + ): boolean { + if (!config.type || !['command', 'plugin'].includes(config.type)) { + debugLogger.warn( + `Invalid hook ${eventName} from ${source} type: ${config.type}`, + ); + return false; + } + + if (config.type === 'command' && !config.command) { + debugLogger.warn( + `Command hook ${eventName} from ${source} missing command field`, + ); + return false; + } + + return true; + } + + /** + * Check if an event name is valid + */ + private isValidEventName(eventName: string): eventName is HookEventName { + const validEventNames: string[] = Object.values(HookEventName); + return validEventNames.includes(eventName); + } + + /** + * Get source priority (lower number = higher priority) + */ + private getSourcePriority(source: HooksConfigSource): number { + switch (source) { + case HooksConfigSource.Project: + return 1; + case HooksConfigSource.User: + return 2; + case HooksConfigSource.System: + return 3; + case HooksConfigSource.Extensions: + return 4; + default: + return 999; + } + } +} diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts new file mode 100644 index 0000000000..6be326ef0a --- /dev/null +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -0,0 +1,684 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HookRunner } from './hookRunner.js'; +import { HookEventName, HookType, HooksConfigSource } from './types.js'; +import type { HookConfig, HookInput } from './types.js'; + +// Hoisted mock +const mockSpawn = vi.hoisted(() => vi.fn()); + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + spawn: mockSpawn, + }; +}); + +describe('HookRunner', () => { + let hookRunner: HookRunner; + + beforeEach(() => { + hookRunner = new HookRunner(); + vi.clearAllMocks(); + }); + + const createMockInput = (overrides: Partial = {}): HookInput => ({ + session_id: 'test-session', + transcript_path: '/test/transcript', + cwd: '/test', + hook_event_name: 'test-event', + timestamp: '2024-01-01T00:00:00Z', + ...overrides, + }); + + const createMockProcess = ( + exitCode: number = 0, + stdout: string = '', + stderr: string = '', + ) => { + const mockProcess = { + stdin: { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + }, + stdout: { + on: vi.fn((event: string, callback: (data: Buffer) => void) => { + if (event === 'data' && stdout) { + setTimeout(() => callback(Buffer.from(stdout)), 0); + } + }), + }, + stderr: { + on: vi.fn((event: string, callback: (data: Buffer) => void) => { + if (event === 'data' && stderr) { + setTimeout(() => callback(Buffer.from(stderr)), 0); + } + }), + }, + on: vi.fn((event: string, callback: (code: number) => void) => { + if (event === 'close') { + setTimeout(() => callback(exitCode), 0); + } + }), + kill: vi.fn(), + }; + return mockProcess; + }; + + describe('executeHook', () => { + it('should return error when hook command is missing', async () => { + const hookConfig: HookConfig = { + type: HookType.Command, + command: '', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.error?.message).toBe('Command hook missing command'); + }); + + it('should execute hook and return success for exit code 0', async () => { + const mockProcess = createMockProcess(0, 'hello'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo hello', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('hello'); + expect(mockSpawn).toHaveBeenCalled(); + }); + + it('should return failure for non-zero exit code', async () => { + const mockProcess = createMockProcess(1, '', 'error'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'exit 1', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(1); + }); + + it('should parse JSON output from stdout', async () => { + const output = JSON.stringify({ + decision: 'allow', + systemMessage: 'test', + }); + const mockProcess = createMockProcess(0, output); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo json', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.output?.decision).toBe('allow'); + expect(result.output?.systemMessage).toBe('test'); + }); + + it('should convert plain text to allow output on success', async () => { + const mockProcess = createMockProcess(0, 'some text output'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo text', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.output?.decision).toBe('allow'); + expect(result.output?.systemMessage).toBe('some text output'); + }); + + it('should convert plain text to deny output on exit code 2', async () => { + const mockProcess = createMockProcess(2, '', 'error message'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo error && exit 2', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.output?.decision).toBe('deny'); + expect(result.output?.reason).toBe('error message'); + }); + + it('should ignore stdout on exit code 2 and use stderr only', async () => { + // Exit code 2 should ignore stdout and use stderr as the error message + const mockProcess = createMockProcess( + 2, + 'stdout should be ignored', + 'stderr error message', + ); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo stdout && echo stderr >&2 && exit 2', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.output?.decision).toBe('deny'); + expect(result.output?.reason).toBe('stderr error message'); + }); + + it('should not parse JSON on exit code 2', async () => { + // Exit code 2 should ignore JSON in stdout + const mockProcess = createMockProcess( + 2, + '{"decision":"allow"}', + 'blocking error', + ); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo json && exit 2', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + // Should NOT parse JSON, should use stderr as reason + expect(result.success).toBe(false); + expect(result.output?.decision).toBe('deny'); + expect(result.output?.reason).toBe('blocking error'); + }); + + it('should handle exit code 1 as non-blocking warning', async () => { + const mockProcess = createMockProcess(1, '', 'warning'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'exit 1', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.output?.decision).toBe('allow'); + expect(result.output?.systemMessage).toBe('Warning: warning'); + }); + + it('should include duration in result', async () => { + const mockProcess = createMockProcess(0, 'test'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.duration).toBeGreaterThanOrEqual(0); + }); + + it('should handle process error', async () => { + const mockProcess = { + stdin: { on: vi.fn(), write: vi.fn(), end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, callback: (error: Error) => void) => { + if (event === 'error') { + callback(new Error('spawn error')); + } + }), + kill: vi.fn(), + }; + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('executeHooksParallel', () => { + it('should execute multiple hooks in parallel', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfigs: HookConfig[] = [ + { + type: HookType.Command, + command: 'echo hook1', + source: HooksConfigSource.Project, + }, + { + type: HookType.Command, + command: 'echo hook2', + source: HooksConfigSource.Project, + }, + ]; + const input = createMockInput(); + + const results = await hookRunner.executeHooksParallel( + hookConfigs, + HookEventName.PreToolUse, + input, + ); + + expect(results).toHaveLength(2); + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(true); + }); + + it('should call onHookStart and onHookEnd callbacks', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfigs: HookConfig[] = [ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]; + const input = createMockInput(); + const onHookStart = vi.fn(); + const onHookEnd = vi.fn(); + + await hookRunner.executeHooksParallel( + hookConfigs, + HookEventName.PreToolUse, + input, + onHookStart, + onHookEnd, + ); + + expect(onHookStart).toHaveBeenCalledTimes(1); + expect(onHookEnd).toHaveBeenCalledTimes(1); + }); + }); + + describe('executeHooksSequential', () => { + it('should execute hooks sequentially', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfigs: HookConfig[] = [ + { + type: HookType.Command, + command: 'echo first', + source: HooksConfigSource.Project, + }, + { + type: HookType.Command, + command: 'echo second', + source: HooksConfigSource.Project, + }, + ]; + const input = createMockInput(); + + const results = await hookRunner.executeHooksSequential( + hookConfigs, + HookEventName.PreToolUse, + input, + ); + + expect(results).toHaveLength(2); + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(true); + }); + + it('should call onHookStart and onHookEnd callbacks', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfigs: HookConfig[] = [ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]; + const input = createMockInput(); + const onHookStart = vi.fn(); + const onHookEnd = vi.fn(); + + await hookRunner.executeHooksSequential( + hookConfigs, + HookEventName.PreToolUse, + input, + onHookStart, + onHookEnd, + ); + + expect(onHookStart).toHaveBeenCalledTimes(1); + expect(onHookEnd).toHaveBeenCalledTimes(1); + }); + }); + + describe('output truncation', () => { + it('should truncate stdout when exceeding MAX_OUTPUT_LENGTH', async () => { + // Create a process that outputs more than 1MB of data + const largeOutput = 'x'.repeat(2 * 1024 * 1024); // 2MB + const mockProcess = createMockProcess(0, largeOutput); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo large', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + // stdout should be truncated to MAX_OUTPUT_LENGTH (1MB) + expect(result.stdout?.length).toBeLessThanOrEqual(1024 * 1024); + }); + + it('should truncate stderr when exceeding MAX_OUTPUT_LENGTH', async () => { + const largeOutput = 'x'.repeat(2 * 1024 * 1024); // 2MB + const mockProcess = createMockProcess(0, '', largeOutput); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo large', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + // stderr should be truncated to MAX_OUTPUT_LENGTH (1MB) + expect(result.stderr?.length).toBeLessThanOrEqual(1024 * 1024); + }); + + it('should handle partial truncation gracefully', async () => { + // Output exactly at the limit + const exactOutput = 'x'.repeat(1024 * 1024); // 1MB exactly + const mockProcess = createMockProcess(0, exactOutput); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo exact', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.stdout?.length).toBe(1024 * 1024); + }); + }); + + describe('expandCommand', () => { + it('should expand GEMINI_PROJECT_DIR placeholder', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo $GEMINI_PROJECT_DIR', + source: HooksConfigSource.Project, + }; + const input = createMockInput({ cwd: '/test/project' }); + + await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input); + + // Verify spawn was called with expanded command + const spawnCall = mockSpawn.mock.calls[0]; + const command = spawnCall[1][spawnCall[1].length - 1]; // Last arg is the command + expect(command).toContain('/test/project'); + }); + + it('should expand CLAUDE_PROJECT_DIR placeholder for compatibility', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo $CLAUDE_PROJECT_DIR', + source: HooksConfigSource.Project, + }; + const input = createMockInput({ cwd: '/test/project' }); + + await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input); + + const spawnCall = mockSpawn.mock.calls[0]; + const command = spawnCall[1][spawnCall[1].length - 1]; // Last arg is the command + expect(command).toContain('/test/project'); + }); + + it('should not modify command without placeholders', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo hello', + source: HooksConfigSource.Project, + }; + const input = createMockInput({ cwd: '/test/project' }); + + await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input); + + const spawnCall = mockSpawn.mock.calls[0]; + const command = spawnCall[1][spawnCall[1].length - 1]; // Last arg is the command + expect(command).toBe('echo hello'); + }); + }); + + describe('convertPlainTextToHookOutput', () => { + it('should convert plain text to allow output on success', async () => { + const mockProcess = createMockProcess(0, 'plain text response'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo text', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.output?.decision).toBe('allow'); + expect(result.output?.systemMessage).toBe('plain text response'); + }); + + it('should convert non-zero exit code to deny output', async () => { + const mockProcess = createMockProcess(3, '', 'error message'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'exit 3', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.output?.decision).toBe('deny'); + expect(result.output?.reason).toBe('error message'); + }); + + it('should use stderr when stdout is empty on success', async () => { + const mockProcess = createMockProcess(0, '', 'stderr output'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.output?.systemMessage).toBe('stderr output'); + }); + + it('should handle empty output gracefully', async () => { + const mockProcess = createMockProcess(0, '', ''); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.output).toBeUndefined(); + }); + + it('should parse nested JSON strings', async () => { + const nestedJson = JSON.stringify(JSON.stringify({ decision: 'allow' })); + const mockProcess = createMockProcess(0, nestedJson); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo json', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.output?.decision).toBe('allow'); + }); + }); +}); diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts new file mode 100644 index 0000000000..c688e43247 --- /dev/null +++ b/packages/core/src/hooks/hookRunner.ts @@ -0,0 +1,427 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import { HookEventName } from './types.js'; +import type { + HookConfig, + HookInput, + HookOutput, + HookExecutionResult, + PreToolUseInput, + UserPromptSubmitInput, +} from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { + escapeShellArg, + getShellConfiguration, + type ShellType, +} from '../utils/shell-utils.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +/** + * Default timeout for hook execution (60 seconds) + */ +const DEFAULT_HOOK_TIMEOUT = 60000; + +/** + * Maximum length for stdout/stderr output (1MB) + * Prevents memory issues from unbounded output + */ +const MAX_OUTPUT_LENGTH = 1024 * 1024; + +/** + * Exit code constants for hook execution + */ +const EXIT_CODE_SUCCESS = 0; +const EXIT_CODE_NON_BLOCKING_ERROR = 1; + +/** + * Hook runner that executes command hooks + */ +export class HookRunner { + /** + * Execute a single hook + */ + async executeHook( + hookConfig: HookConfig, + eventName: HookEventName, + input: HookInput, + ): Promise { + const startTime = Date.now(); + + try { + return await this.executeCommandHook( + hookConfig, + eventName, + input, + startTime, + ); + } catch (error) { + const duration = Date.now() - startTime; + const hookId = hookConfig.name || hookConfig.command || 'unknown'; + const errorMessage = `Hook execution failed for event '${eventName}' (hook: ${hookId}): ${error}`; + debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`); + + return { + hookConfig, + eventName, + success: false, + error: error instanceof Error ? error : new Error(errorMessage), + duration, + }; + } + } + + /** + * Execute multiple hooks in parallel + */ + async executeHooksParallel( + hookConfigs: HookConfig[], + eventName: HookEventName, + input: HookInput, + onHookStart?: (config: HookConfig, index: number) => void, + onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void, + ): Promise { + const promises = hookConfigs.map(async (config, index) => { + onHookStart?.(config, index); + const result = await this.executeHook(config, eventName, input); + onHookEnd?.(config, result); + return result; + }); + + return Promise.all(promises); + } + + /** + * Execute multiple hooks sequentially + */ + async executeHooksSequential( + hookConfigs: HookConfig[], + eventName: HookEventName, + input: HookInput, + onHookStart?: (config: HookConfig, index: number) => void, + onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void, + ): Promise { + const results: HookExecutionResult[] = []; + let currentInput = input; + + for (let i = 0; i < hookConfigs.length; i++) { + const config = hookConfigs[i]; + onHookStart?.(config, i); + const result = await this.executeHook(config, eventName, currentInput); + onHookEnd?.(config, result); + results.push(result); + + // If the hook succeeded and has output, use it to modify the input for the next hook + if (result.success && result.output) { + currentInput = this.applyHookOutputToInput( + currentInput, + result.output, + eventName, + ); + } + } + + return results; + } + + /** + * Apply hook output to modify input for the next hook in sequential execution + */ + private applyHookOutputToInput( + originalInput: HookInput, + hookOutput: HookOutput, + eventName: HookEventName, + ): HookInput { + // Create a copy of the original input + const modifiedInput = { ...originalInput }; + + // Apply modifications based on hook output and event type + if (hookOutput.hookSpecificOutput) { + switch (eventName) { + case HookEventName.UserPromptSubmit: + if ('additionalContext' in hookOutput.hookSpecificOutput) { + // For UserPromptSubmit, we could modify the prompt with additional context + const additionalContext = + hookOutput.hookSpecificOutput['additionalContext']; + if ( + typeof additionalContext === 'string' && + 'prompt' in modifiedInput + ) { + (modifiedInput as UserPromptSubmitInput).prompt += + '\n\n' + additionalContext; + } + } + break; + + case HookEventName.PreToolUse: + if ('tool_input' in hookOutput.hookSpecificOutput) { + const newToolInput = hookOutput.hookSpecificOutput[ + 'tool_input' + ] as Record; + if (newToolInput && 'tool_input' in modifiedInput) { + (modifiedInput as PreToolUseInput).tool_input = { + ...(modifiedInput as PreToolUseInput).tool_input, + ...newToolInput, + }; + } + } + break; + + default: + // For other events, no special input modification is needed + break; + } + } + + return modifiedInput; + } + + /** + * Execute a command hook + */ + private async executeCommandHook( + hookConfig: HookConfig, + eventName: HookEventName, + input: HookInput, + startTime: number, + ): Promise { + const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT; + + return new Promise((resolve) => { + if (!hookConfig.command) { + const errorMessage = 'Command hook missing command'; + debugLogger.warn( + `Hook configuration error (non-fatal): ${errorMessage}`, + ); + resolve({ + hookConfig, + eventName, + success: false, + error: new Error(errorMessage), + duration: Date.now() - startTime, + }); + return; + } + + let stdout = ''; + let stderr = ''; + let timedOut = false; + + const shellConfig = getShellConfiguration(); + const command = this.expandCommand( + hookConfig.command, + input, + shellConfig.shell, + ); + + const env = { + ...process.env, + GEMINI_PROJECT_DIR: input.cwd, + CLAUDE_PROJECT_DIR: input.cwd, // For compatibility + QWEN_PROJECT_DIR: input.cwd, // For Qwen Code compatibility + ...hookConfig.env, + }; + + const child = spawn( + shellConfig.executable, + [...shellConfig.argsPrefix, command], + { + env, + cwd: input.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + }, + ); + + // Set up timeout + const timeoutHandle = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + + // Force kill after 5 seconds + setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } + }, 5000); + }, timeout); + + // Send input to stdin + if (child.stdin) { + child.stdin.on('error', (err: NodeJS.ErrnoException) => { + // Ignore EPIPE errors which happen when the child process closes stdin early + if (err.code !== 'EPIPE') { + debugLogger.debug(`Hook stdin error: ${err}`); + } + }); + + // Wrap write operations in try-catch to handle synchronous EPIPE errors + // that occur when the child process exits before we finish writing + try { + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + } catch (err) { + // Ignore EPIPE errors which happen when the child process closes stdin early + if (err instanceof Error && 'code' in err && err.code !== 'EPIPE') { + debugLogger.debug(`Hook stdin write error: ${err}`); + } + } + } + + // Collect stdout + child.stdout?.on('data', (data: Buffer) => { + if (stdout.length < MAX_OUTPUT_LENGTH) { + const remaining = MAX_OUTPUT_LENGTH - stdout.length; + stdout += data.slice(0, remaining).toString(); + if (data.length > remaining) { + debugLogger.warn( + `Hook stdout exceeded max length (${MAX_OUTPUT_LENGTH} bytes), truncating`, + ); + } + } + }); + + // Collect stderr + child.stderr?.on('data', (data: Buffer) => { + if (stderr.length < MAX_OUTPUT_LENGTH) { + const remaining = MAX_OUTPUT_LENGTH - stderr.length; + stderr += data.slice(0, remaining).toString(); + if (data.length > remaining) { + debugLogger.warn( + `Hook stderr exceeded max length (${MAX_OUTPUT_LENGTH} bytes), truncating`, + ); + } + } + }); + + // Handle process exit + child.on('close', (exitCode) => { + clearTimeout(timeoutHandle); + const duration = Date.now() - startTime; + + if (timedOut) { + resolve({ + hookConfig, + eventName, + success: false, + error: new Error(`Hook timed out after ${timeout}ms`), + stdout, + stderr, + duration, + }); + return; + } + + // Parse output + // Exit code 2 is a blocking error - ignore stdout, use stderr only + let output: HookOutput | undefined; + const isBlockingError = exitCode === 2; + + // For exit code 2, only use stderr (ignore stdout) + const textToParse = isBlockingError + ? stderr.trim() + : stdout.trim() || stderr.trim(); + + if (textToParse) { + // Only parse JSON on exit 0 + if (!isBlockingError) { + try { + let parsed = JSON.parse(textToParse); + if (typeof parsed === 'string') { + parsed = JSON.parse(parsed); + } + if (parsed && typeof parsed === 'object') { + output = parsed as HookOutput; + } + } catch { + // Not JSON, convert plain text to structured output + output = this.convertPlainTextToHookOutput( + textToParse, + exitCode || EXIT_CODE_SUCCESS, + ); + } + } else { + // Exit code 2: blocking error, use stderr as reason + output = this.convertPlainTextToHookOutput(textToParse, exitCode); + } + } + + resolve({ + hookConfig, + eventName, + success: exitCode === EXIT_CODE_SUCCESS, + output, + stdout, + stderr, + exitCode: exitCode || EXIT_CODE_SUCCESS, + duration, + }); + }); + + // Handle process errors + child.on('error', (error) => { + clearTimeout(timeoutHandle); + const duration = Date.now() - startTime; + + resolve({ + hookConfig, + eventName, + success: false, + error, + stdout, + stderr, + duration, + }); + }); + }); + } + + /** + * Expand command with environment variables and input context + */ + private expandCommand( + command: string, + input: HookInput, + shellType: ShellType, + ): string { + debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`); + const escapedCwd = escapeShellArg(input.cwd, shellType); + return command + .replace(/\$GEMINI_PROJECT_DIR/g, () => escapedCwd) + .replace(/\$CLAUDE_PROJECT_DIR/g, () => escapedCwd); // For compatibility + } + + /** + * Convert plain text output to structured HookOutput + */ + private convertPlainTextToHookOutput( + text: string, + exitCode: number, + ): HookOutput { + if (exitCode === EXIT_CODE_SUCCESS) { + // Success - treat as system message or additional context + return { + decision: 'allow', + systemMessage: text, + }; + } else if (exitCode === EXIT_CODE_NON_BLOCKING_ERROR) { + // Non-blocking error (EXIT_CODE_NON_BLOCKING_ERROR = 1) + return { + decision: 'allow', + systemMessage: `Warning: ${text}`, + }; + } else { + // All other non-zero exit codes (including 2) are blocking + return { + decision: 'deny', + reason: text, + }; + } + } +} diff --git a/packages/core/src/hooks/hookSystem.test.ts b/packages/core/src/hooks/hookSystem.test.ts new file mode 100644 index 0000000000..51f2d30506 --- /dev/null +++ b/packages/core/src/hooks/hookSystem.test.ts @@ -0,0 +1,328 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HookSystem } from './hookSystem.js'; +import { HookRegistry } from './hookRegistry.js'; +import { HookRunner } from './hookRunner.js'; +import { HookAggregator } from './hookAggregator.js'; +import { HookPlanner } from './hookPlanner.js'; +import { HookEventHandler } from './hookEventHandler.js'; +import { + HookType, + HooksConfigSource, + HookEventName, + type HookDecision, +} from './types.js'; +import type { Config } from '../config/config.js'; + +vi.mock('./hookRegistry.js'); +vi.mock('./hookRunner.js'); +vi.mock('./hookAggregator.js'); +vi.mock('./hookPlanner.js'); +vi.mock('./hookEventHandler.js'); + +describe('HookSystem', () => { + let mockConfig: Config; + let mockHookRegistry: HookRegistry; + let mockHookRunner: HookRunner; + let mockHookAggregator: HookAggregator; + let mockHookPlanner: HookPlanner; + let mockHookEventHandler: HookEventHandler; + let hookSystem: HookSystem; + + beforeEach(() => { + mockConfig = { + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'), + getWorkingDir: vi.fn().mockReturnValue('/test/cwd'), + } as unknown as Config; + + mockHookRegistry = { + initialize: vi.fn().mockResolvedValue(undefined), + setHookEnabled: vi.fn(), + getAllHooks: vi.fn().mockReturnValue([]), + } as unknown as HookRegistry; + + mockHookRunner = { + executeHooksSequential: vi.fn(), + executeHooksParallel: vi.fn(), + } as unknown as HookRunner; + + mockHookAggregator = { + aggregateResults: vi.fn(), + } as unknown as HookAggregator; + + mockHookPlanner = { + createExecutionPlan: vi.fn(), + } as unknown as HookPlanner; + + mockHookEventHandler = { + fireUserPromptSubmitEvent: vi.fn(), + fireStopEvent: vi.fn(), + } as unknown as HookEventHandler; + + vi.mocked(HookRegistry).mockImplementation(() => mockHookRegistry); + vi.mocked(HookRunner).mockImplementation(() => mockHookRunner); + vi.mocked(HookAggregator).mockImplementation(() => mockHookAggregator); + vi.mocked(HookPlanner).mockImplementation(() => mockHookPlanner); + vi.mocked(HookEventHandler).mockImplementation(() => mockHookEventHandler); + + hookSystem = new HookSystem(mockConfig); + }); + + describe('constructor', () => { + it('should create instance with all dependencies', () => { + expect(HookRegistry).toHaveBeenCalledWith(mockConfig); + expect(HookRunner).toHaveBeenCalled(); + expect(HookAggregator).toHaveBeenCalled(); + expect(HookPlanner).toHaveBeenCalledWith(mockHookRegistry); + expect(HookEventHandler).toHaveBeenCalledWith( + mockConfig, + mockHookPlanner, + mockHookRunner, + mockHookAggregator, + ); + }); + }); + + describe('initialize', () => { + it('should initialize hook registry', async () => { + await hookSystem.initialize(); + + expect(mockHookRegistry.initialize).toHaveBeenCalled(); + }); + }); + + describe('getEventHandler', () => { + it('should return the hook event handler', () => { + const eventHandler = hookSystem.getEventHandler(); + + expect(eventHandler).toBe(mockHookEventHandler); + }); + }); + + describe('getRegistry', () => { + it('should return the hook registry', () => { + const registry = hookSystem.getRegistry(); + + expect(registry).toBe(mockHookRegistry); + }); + }); + + describe('setHookEnabled', () => { + it('should enable a hook', () => { + hookSystem.setHookEnabled('test-hook', true); + + expect(mockHookRegistry.setHookEnabled).toHaveBeenCalledWith( + 'test-hook', + true, + ); + }); + + it('should disable a hook', () => { + hookSystem.setHookEnabled('test-hook', false); + + expect(mockHookRegistry.setHookEnabled).toHaveBeenCalledWith( + 'test-hook', + false, + ); + }); + }); + + describe('getAllHooks', () => { + it('should return all registered hooks', () => { + const mockHooks = [ + { + config: { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + enabled: true, + }, + ]; + vi.mocked(mockHookRegistry.getAllHooks).mockReturnValue(mockHooks); + + const hooks = hookSystem.getAllHooks(); + + expect(hooks).toEqual(mockHooks); + expect(mockHookRegistry.getAllHooks).toHaveBeenCalled(); + }); + }); + + describe('fireStopEvent', () => { + it('should fire stop event and return output', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 50, + finalOutput: { + continue: false, + stopReason: 'user_stop', + }, + }; + vi.mocked(mockHookEventHandler.fireStopEvent).mockResolvedValue( + mockResult, + ); + + const result = await hookSystem.fireStopEvent(true, 'last message'); + + expect(mockHookEventHandler.fireStopEvent).toHaveBeenCalledWith( + true, + 'last message', + ); + expect(result).toBeDefined(); + }); + + it('should use default parameters when not provided', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 0, + finalOutput: undefined, + }; + vi.mocked(mockHookEventHandler.fireStopEvent).mockResolvedValue( + mockResult, + ); + + await hookSystem.fireStopEvent(); + + expect(mockHookEventHandler.fireStopEvent).toHaveBeenCalledWith( + false, + '', + ); + }); + + it('should return undefined when no final output', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 0, + finalOutput: undefined, + }; + vi.mocked(mockHookEventHandler.fireStopEvent).mockResolvedValue( + mockResult, + ); + + const result = await hookSystem.fireStopEvent(); + + expect(result).toBeUndefined(); + }); + }); + + describe('fireUserPromptSubmitEvent', () => { + it('should fire UserPromptSubmit event and return output', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 50, + finalOutput: { + continue: true, + decision: 'allow' as HookDecision, + }, + }; + vi.mocked( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).mockResolvedValue(mockResult); + + const result = await hookSystem.fireUserPromptSubmitEvent('test prompt'); + + expect( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).toHaveBeenCalledWith('test prompt'); + expect(result).toBeDefined(); + }); + + it('should pass prompt to event handler', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 0, + finalOutput: { + decision: 'allow' as HookDecision, + }, + }; + vi.mocked( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).mockResolvedValue(mockResult); + + await hookSystem.fireUserPromptSubmitEvent('my custom prompt'); + + expect( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).toHaveBeenCalledWith('my custom prompt'); + }); + + it('should return undefined when no final output', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 0, + finalOutput: undefined, + }; + vi.mocked( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).mockResolvedValue(mockResult); + + const result = await hookSystem.fireUserPromptSubmitEvent('test'); + + expect(result).toBeUndefined(); + }); + + it('should return DefaultHookOutput with blocking decision', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 50, + finalOutput: { + decision: 'block' as HookDecision, + reason: 'Blocked by policy', + }, + }; + vi.mocked( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).mockResolvedValue(mockResult); + + const result = await hookSystem.fireUserPromptSubmitEvent('test'); + + expect(result).toBeDefined(); + expect(result?.isBlockingDecision()).toBe(true); + }); + + it('should return DefaultHookOutput with additional context', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 50, + finalOutput: { + decision: 'allow' as HookDecision, + hookSpecificOutput: { + additionalContext: 'Some additional context', + }, + }, + }; + vi.mocked( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).mockResolvedValue(mockResult); + + const result = await hookSystem.fireUserPromptSubmitEvent('test'); + + expect(result).toBeDefined(); + expect(result?.getAdditionalContext()).toBe('Some additional context'); + }); + }); +}); diff --git a/packages/core/src/hooks/hookSystem.ts b/packages/core/src/hooks/hookSystem.ts new file mode 100644 index 0000000000..8a40cbd9ef --- /dev/null +++ b/packages/core/src/hooks/hookSystem.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { HookRegistry } from './hookRegistry.js'; +import { HookRunner } from './hookRunner.js'; +import { HookAggregator } from './hookAggregator.js'; +import { HookPlanner } from './hookPlanner.js'; +import { HookEventHandler } from './hookEventHandler.js'; +import type { HookRegistryEntry } from './hookRegistry.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import type { DefaultHookOutput } from './types.js'; +import { createHookOutput } from './types.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +/** + * Main hook system that coordinates all hook-related functionality + */ + +export class HookSystem { + private readonly hookRegistry: HookRegistry; + private readonly hookRunner: HookRunner; + private readonly hookAggregator: HookAggregator; + private readonly hookPlanner: HookPlanner; + private readonly hookEventHandler: HookEventHandler; + + constructor(config: Config) { + // Initialize components + this.hookRegistry = new HookRegistry(config); + this.hookRunner = new HookRunner(); + this.hookAggregator = new HookAggregator(); + this.hookPlanner = new HookPlanner(this.hookRegistry); + this.hookEventHandler = new HookEventHandler( + config, + this.hookPlanner, + this.hookRunner, + this.hookAggregator, + ); + } + + /** + * Initialize the hook system + */ + async initialize(): Promise { + await this.hookRegistry.initialize(); + debugLogger.debug('Hook system initialized successfully'); + } + + /** + * Get the hook event bus for firing events + */ + getEventHandler(): HookEventHandler { + return this.hookEventHandler; + } + + /** + * Get hook registry for management operations + */ + getRegistry(): HookRegistry { + return this.hookRegistry; + } + + /** + * Enable or disable a hook + */ + setHookEnabled(hookName: string, enabled: boolean): void { + this.hookRegistry.setHookEnabled(hookName, enabled); + } + + /** + * Get all registered hooks for display/management + */ + getAllHooks(): HookRegistryEntry[] { + return this.hookRegistry.getAllHooks(); + } + + async fireUserPromptSubmitEvent( + prompt: string, + ): Promise { + const result = + await this.hookEventHandler.fireUserPromptSubmitEvent(prompt); + return result.finalOutput + ? createHookOutput('UserPromptSubmit', result.finalOutput) + : undefined; + } + + async fireStopEvent( + stopHookActive: boolean = false, + lastAssistantMessage: string = '', + ): Promise { + const result = await this.hookEventHandler.fireStopEvent( + stopHookActive, + lastAssistantMessage, + ); + return result.finalOutput + ? createHookOutput('Stop', result.finalOutput) + : undefined; + } +} diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts new file mode 100644 index 0000000000..779f3b3327 --- /dev/null +++ b/packages/core/src/hooks/index.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Export types +export * from './types.js'; + +// Export core components +export { HookSystem } from './hookSystem.js'; +export { HookRegistry } from './hookRegistry.js'; +export { HookRunner } from './hookRunner.js'; +export { HookAggregator } from './hookAggregator.js'; +export { HookPlanner } from './hookPlanner.js'; +export { HookEventHandler } from './hookEventHandler.js'; + +// Export interfaces and enums +export type { HookRegistryEntry } from './hookRegistry.js'; +export { HooksConfigSource as ConfigSource } from './types.js'; +export type { AggregatedHookResult } from './hookAggregator.js'; +export type { HookEventContext } from './hookPlanner.js'; diff --git a/packages/core/src/hooks/trustedHooks.ts b/packages/core/src/hooks/trustedHooks.ts new file mode 100644 index 0000000000..135fcc5b27 --- /dev/null +++ b/packages/core/src/hooks/trustedHooks.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; +import { + getHookKey, + type HookDefinition, + type HookEventName, +} from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +interface TrustedHooksConfig { + [projectPath: string]: string[]; // Array of trusted hook keys (name:command) +} + +export class TrustedHooksManager { + private configPath: string; + private trustedHooks: TrustedHooksConfig = {}; + + constructor() { + this.configPath = path.join( + Storage.getGlobalQwenDir(), + 'trusted_hooks.json', + ); + this.load(); + } + + private load(): void { + try { + if (fs.existsSync(this.configPath)) { + const content = fs.readFileSync(this.configPath, 'utf-8'); + this.trustedHooks = JSON.parse(content); + } + } catch (error) { + debugLogger.warn('Failed to load trusted hooks config', error); + this.trustedHooks = {}; + } + } + + private save(): void { + try { + const dir = path.dirname(this.configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync( + this.configPath, + JSON.stringify(this.trustedHooks, null, 2), + ); + } catch (error) { + debugLogger.warn('Failed to save trusted hooks config', error); + } + } + + /** + * Get untrusted hooks for a project + * @param projectPath Absolute path to the project root + * @param hooks The hooks configuration to check + * @returns List of untrusted hook commands/names + */ + getUntrustedHooks( + projectPath: string, + hooks: { [K in HookEventName]?: HookDefinition[] }, + ): string[] { + const trustedKeys = new Set(this.trustedHooks[projectPath] || []); + const untrusted: string[] = []; + + for (const eventName of Object.keys(hooks)) { + const definitions = hooks[eventName as HookEventName]; + if (!Array.isArray(definitions)) continue; + + for (const def of definitions) { + if (!def || !Array.isArray(def.hooks)) continue; + for (const hook of def.hooks) { + const key = getHookKey(hook); + if (!trustedKeys.has(key)) { + // Return friendly name or command + untrusted.push(hook.name || hook.command || 'unknown-hook'); + } + } + } + } + + return Array.from(new Set(untrusted)); // Deduplicate + } + + /** + * Trust all provided hooks for a project + */ + trustHooks( + projectPath: string, + hooks: { [K in HookEventName]?: HookDefinition[] }, + ): void { + const currentTrusted = new Set(this.trustedHooks[projectPath] || []); + + for (const eventName of Object.keys(hooks)) { + const definitions = hooks[eventName as HookEventName]; + if (!Array.isArray(definitions)) continue; + + for (const def of definitions) { + if (!def || !Array.isArray(def.hooks)) continue; + for (const hook of def.hooks) { + currentTrusted.add(getHookKey(hook)); + } + } + } + + this.trustedHooks[projectPath] = Array.from(currentTrusted); + this.save(); + } +} diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts new file mode 100644 index 0000000000..49ac7a5efe --- /dev/null +++ b/packages/core/src/hooks/types.ts @@ -0,0 +1,678 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum HooksConfigSource { + Project = 'project', + User = 'user', + System = 'system', + Extensions = 'extensions', +} + +/** + * Event names for the hook system + */ +export enum HookEventName { + // PreToolUse - Before tool execution + PreToolUse = 'PreToolUse', + // PostToolUse - After tool execution + PostToolUse = 'PostToolUse', + // PostToolUseFailure - After tool execution fails + PostToolUseFailure = 'PostToolUseFailure', + // Notification - When notifications are sent + Notification = 'Notification', + // UserPromptSubmit - When the user submits a prompt + UserPromptSubmit = 'UserPromptSubmit', + // SessionStart - When a new session is started + SessionStart = 'SessionStart', + // Stop - Right before Claude concludes its response + Stop = 'Stop', + // SubagentStart - When a subagent (Task tool call) is started + SubagentStart = 'SubagentStart', + // SubagentStop - Right before a subagent (Task tool call) concludes its response + SubagentStop = 'SubagentStop', + // PreCompact - Before conversation compaction + PreCompact = 'PreCompact', + // SessionEnd - When a session is ending + SessionEnd = 'SessionEnd', + // When a permission dialog is displayed + PermissionRequest = 'PermissionRequest', +} + +/** + * Fields in the hooks configuration that are not hook event names + */ +export const HOOKS_CONFIG_FIELDS = ['enabled', 'disabled', 'notifications']; + +/** + * Hook configuration entry + */ +export interface CommandHookConfig { + type: HookType.Command; + command: string; + name?: string; + description?: string; + timeout?: number; + source?: HooksConfigSource; + env?: Record; +} + +export type HookConfig = CommandHookConfig; + +/** + * Hook definition with matcher + */ +export interface HookDefinition { + matcher?: string; + sequential?: boolean; + hooks: HookConfig[]; +} + +/** + * Hook implementation types + */ +export enum HookType { + Command = 'command', +} + +/** + * Generate a unique key for a hook configuration + */ +export function getHookKey(hook: HookConfig): string { + const name = hook.name ?? ''; + return name ? `${name}:${hook.command}` : hook.command; +} + +/** + * Decision types for hook outputs + */ +export type HookDecision = 'ask' | 'block' | 'deny' | 'approve' | 'allow'; + +/** + * Base hook input - common fields for all events + */ +export interface HookInput { + session_id: string; + transcript_path: string; + cwd: string; + hook_event_name: string; + timestamp: string; +} + +/** + * Base hook output - common fields for all events + */ +export interface HookOutput { + continue?: boolean; + stopReason?: string; + suppressOutput?: boolean; + systemMessage?: string; + decision?: HookDecision; + reason?: string; + hookSpecificOutput?: Record; +} + +/** + * Factory function to create the appropriate hook output class based on event name + * Returns specialized HookOutput subclasses for events with specific methods + */ +export function createHookOutput( + eventName: string, + data: Partial, +): DefaultHookOutput { + switch (eventName) { + case HookEventName.PreToolUse: + return new PreToolUseHookOutput(data); + case HookEventName.Stop: + return new StopHookOutput(data); + case HookEventName.PermissionRequest: + return new PermissionRequestHookOutput(data); + default: + return new DefaultHookOutput(data); + } +} + +/** + * Default implementation of HookOutput with utility methods + */ +export class DefaultHookOutput implements HookOutput { + continue?: boolean; + stopReason?: string; + suppressOutput?: boolean; + systemMessage?: string; + decision?: HookDecision; + reason?: string; + hookSpecificOutput?: Record; + + constructor(data: Partial = {}) { + this.continue = data.continue; + this.stopReason = data.stopReason; + this.suppressOutput = data.suppressOutput; + this.systemMessage = data.systemMessage; + this.decision = data.decision; + this.reason = data.reason; + this.hookSpecificOutput = data.hookSpecificOutput; + } + + /** + * Check if this output represents a blocking decision + */ + isBlockingDecision(): boolean { + return this.decision === 'block' || this.decision === 'deny'; + } + + /** + * Check if this output requests to stop execution + */ + shouldStopExecution(): boolean { + return this.continue === false; + } + + /** + * Get the effective reason for blocking or stopping + */ + getEffectiveReason(): string { + return this.stopReason || this.reason || 'No reason provided'; + } + + /** + * Get sanitized additional context for adding to responses. + */ + getAdditionalContext(): string | undefined { + if ( + this.hookSpecificOutput && + 'additionalContext' in this.hookSpecificOutput + ) { + const context = this.hookSpecificOutput['additionalContext']; + if (typeof context !== 'string') { + return undefined; + } + + // Sanitize by escaping < and > to prevent tag injection + return context.replace(//g, '>'); + } + return undefined; + } + + /** + * Check if execution should be blocked and return error info + */ + getBlockingError(): { blocked: boolean; reason: string } { + if (this.isBlockingDecision()) { + return { + blocked: true, + reason: this.getEffectiveReason(), + }; + } + return { blocked: false, reason: '' }; + } + + /** + * Check if context clearing was requested by hook. + */ + shouldClearContext(): boolean { + return false; + } +} + +/** + * Specific hook output class for PreToolUse events. + */ +export class PreToolUseHookOutput extends DefaultHookOutput { + /** + * Get modified tool input if provided by hook + */ + getModifiedToolInput(): Record | undefined { + if (this.hookSpecificOutput && 'tool_input' in this.hookSpecificOutput) { + const input = this.hookSpecificOutput['tool_input']; + if ( + typeof input === 'object' && + input !== null && + !Array.isArray(input) + ) { + return input as Record; + } + } + return undefined; + } +} + +/** + * Specific hook output class for Stop events. + */ +export class StopHookOutput extends DefaultHookOutput { + override stopReason?: string; + + constructor(data: Partial = {}) { + super(data); + this.stopReason = data.stopReason; + } + + /** + * Get the stop reason if provided + */ + getStopReason(): string | undefined { + if (!this.stopReason) { + return undefined; + } + return `Stop hook feedback:\n${this.stopReason}`; + } +} + +/** + * Permission suggestion type + */ +export interface PermissionSuggestion { + type: string; + tool?: string; +} + +/** + * Input for PermissionRequest hook events + */ +export interface PermissionRequestInput extends HookInput { + permission_mode: PermissionMode; + tool_name: string; + tool_input: Record; + permission_suggestions?: PermissionSuggestion[]; +} + +/** + * Decision object for PermissionRequest hooks + */ +export interface PermissionRequestDecision { + behavior: 'allow' | 'deny'; + updatedInput?: Record; + updatedPermissions?: PermissionSuggestion[]; + message?: string; + interrupt?: boolean; +} + +/** + * Specific hook output class for PermissionRequest events. + */ +export class PermissionRequestHookOutput extends DefaultHookOutput { + /** + * Get the permission decision if provided by hook + */ + getPermissionDecision(): PermissionRequestDecision | undefined { + if (this.hookSpecificOutput && 'decision' in this.hookSpecificOutput) { + const decision = this.hookSpecificOutput['decision']; + if ( + typeof decision === 'object' && + decision !== null && + !Array.isArray(decision) + ) { + return decision as PermissionRequestDecision; + } + } + return undefined; + } + + /** + * Check if the permission was denied + */ + isPermissionDenied(): boolean { + const decision = this.getPermissionDecision(); + return decision?.behavior === 'deny'; + } + + /** + * Get the deny message if permission was denied + */ + getDenyMessage(): string | undefined { + const decision = this.getPermissionDecision(); + return decision?.message; + } + + /** + * Check if execution should be interrupted after denial + */ + shouldInterrupt(): boolean { + const decision = this.getPermissionDecision(); + return decision?.interrupt === true; + } + + /** + * Get updated tool input if permission was allowed with modifications + */ + getUpdatedToolInput(): Record | undefined { + const decision = this.getPermissionDecision(); + return decision?.updatedInput; + } + + /** + * Get updated permissions if permission was allowed with permission updates + */ + getUpdatedPermissions(): PermissionSuggestion[] | undefined { + const decision = this.getPermissionDecision(); + return decision?.updatedPermissions; + } +} + +/** + * Context for MCP tool executions. + * Contains non-sensitive connection information about the MCP server + * identity. Since server_name is user controlled and arbitrary, we + * also include connection information (e.g., command or url) to + * help identify the MCP server. + * + * NOTE: In the future, consider defining a shared sanitized interface + * from MCPServerConfig to avoid duplication and ensure consistency. + */ +export interface McpToolContext { + server_name: string; + tool_name: string; // Original tool name from the MCP server + + // Connection info (mutually exclusive based on transport type) + command?: string; // For stdio transport + args?: string[]; // For stdio transport + cwd?: string; // For stdio transport + + url?: string; // For SSE/HTTP transport + + tcp?: string; // For WebSocket transport +} + +export interface PreToolUseInput extends HookInput { + permission_mode?: PermissionMode; + tool_name: string; + tool_input: Record; + mcp_context?: McpToolContext; + original_request_name?: string; +} + +/** + * PreToolUse hook output + */ +export interface PreToolUseOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'PreToolUse'; + tool_input?: Record; + }; +} + +/** + * PostToolUse hook input + */ +export interface PostToolUseInput extends HookInput { + tool_name: string; + tool_input: Record; + tool_response: Record; + mcp_context?: McpToolContext; + original_request_name?: string; +} + +/** + * PostToolUse hook output + */ +export interface PostToolUseOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'PostToolUse'; + additionalContext?: string; + + /** + * Optional request to execute another tool immediately after this one. + * The result of this tail call will replace the original tool's response. + */ + tailToolCallRequest?: { + name: string; + args: Record; + }; + }; +} + +/** + * PostToolUseFailure hook input + * Fired when a tool execution fails + */ +export interface PostToolUseFailureInput extends HookInput { + tool_use_id: string; // Unique identifier for the tool use + tool_name: string; + tool_input: Record; + error: string; // Error message describing the failure + error_type?: string; // Type of error (e.g., 'timeout', 'network', 'permission', etc.) + is_interrupt?: boolean; // Whether the failure was caused by user interruption +} + +/** + * PostToolUseFailure hook output + * Supports all three hook types: command, prompt, and agent + */ +export interface PostToolUseFailureOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'PostToolUseFailure'; + additionalContext?: string; + }; +} + +/** + * UserPromptSubmit hook input + */ +export interface UserPromptSubmitInput extends HookInput { + prompt: string; +} + +/** + * UserPromptSubmit hook output + */ +export interface UserPromptSubmitOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'UserPromptSubmit'; + additionalContext?: string; + }; +} + +/** + * Notification types + */ +export enum NotificationType { + ToolPermission = 'ToolPermission', +} + +/** + * Notification hook input + */ +export interface NotificationInput extends HookInput { + permission_mode?: PermissionMode; + notification_type: NotificationType; + message: string; + title?: string; + details: Record; +} + +/** + * Notification hook output + */ +export interface NotificationOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'Notification'; + additionalContext?: string; + }; +} + +/** + * Stop hook input + */ +export interface StopInput extends HookInput { + stop_hook_active: boolean; + last_assistant_message: string; +} + +/** + * Stop hook output + */ +export interface StopOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'Stop'; + additionalContext?: string; + }; +} + +/** + * SessionStart source types + */ +export enum SessionStartSource { + Startup = 'startup', + Resume = 'resume', + Clear = 'clear', + Compact = 'compact', +} + +export enum PermissionMode { + Default = 'default', + Plan = 'plan', + AcceptEdit = 'accept_edit', + DontAsk = 'dont_ask', + BypassPermissions = 'bypass_permissions', +} + +/** + * SessionStart hook input + */ +export interface SessionStartInput extends HookInput { + permission_mode?: PermissionMode; + source: SessionStartSource; + model?: string; +} + +/** + * SessionStart hook output + */ +export interface SessionStartOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'SessionStart'; + additionalContext?: string; + }; +} + +/** + * SessionEnd reason types + */ +export enum SessionEndReason { + Clear = 'clear', + Logout = 'logout', + PromptInputExit = 'prompt_input_exit', + Bypass_permissions_disabled = 'bypass_permissions_disabled', + Other = 'other', +} + +/** + * SessionEnd hook input + */ +export interface SessionEndInput extends HookInput { + reason: SessionEndReason; +} + +/** + * SessionEnd hook output + */ +export interface SessionEndOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'SessionEnd'; + additionalContext?: string; + }; +} + +/** + * PreCompress trigger types + */ +export enum PreCompactTrigger { + Manual = 'manual', + Auto = 'auto', +} + +/** + * PreCompress hook input + */ +export interface PreCompactInput extends HookInput { + trigger: PreCompactTrigger; + custom_instructions?: string; +} + +/** + * PreCompress hook output + */ +export interface PreCompactOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'PreCompact'; + additionalContext?: string; + }; +} + +export enum AgentType { + Bash = 'Bash', + Explorer = 'Explorer', + Plan = 'Plan', + Custom = 'Custom', +} + +/** + * SubagentStart hook input + * Fired when a subagent (Task tool call) is started + */ +export interface SubagentStartInput extends HookInput { + permission_mode?: PermissionMode; + agent_id: string; + agent_type: AgentType; +} + +/** + * SubagentStart hook output + */ +export interface SubagentStartOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'SubagentStart'; + additionalContext?: string; + }; +} + +/** + * SubagentStop hook input + * Fired right before a subagent (Task tool call) concludes its response + */ +export interface SubagentStopInput extends HookInput { + permission_mode?: PermissionMode; + stop_hook_active: boolean; + agent_id: string; + agent_type: AgentType; + agent_transcript_path: string; + last_assistant_message: string; +} + +/** + * SubagentStop hook output + * Supports all three hook types: command, prompt, and agent + */ +export interface SubagentStopOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'SubagentStop'; + additionalContext?: string; + }; +} + +/** + * Hook execution result + */ +export interface HookExecutionResult { + hookConfig: HookConfig; + eventName: HookEventName; + success: boolean; + output?: HookOutput; + stdout?: string; + stderr?: string; + exitCode?: number; + duration: number; + error?: Error; +} + +/** + * Hook execution plan for an event + */ +export interface HookExecutionPlan { + eventName: HookEventName; + hookConfigs: HookConfig[]; + sequential: boolean; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2800e20f61..1d4fa4c20d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -300,3 +300,8 @@ export * from './qwen/qwenOAuth2.js'; export { makeFakeConfig } from './test-utils/config.js'; export * from './test-utils/index.js'; + +// Export hook types and components +export * from './hooks/types.js'; +export { HookSystem, HookRegistry } from './hooks/index.js'; +export type { HookRegistryEntry } from './hooks/index.js';