diff --git a/integration-tests/hooks-agent-flow.test.ts b/integration-tests/hooks-agent-flow.test.ts index 13eb0bcecc..b761a6c82b 100644 --- a/integration-tests/hooks-agent-flow.test.ts +++ b/integration-tests/hooks-agent-flow.test.ts @@ -48,7 +48,10 @@ describe('Hooks Agent Flow', () => { console.error('DEBUG: BeforeAgent hook executed'); `; - const scriptPath = join(rig.testDir!, 'before_agent_context.cjs'); + const scriptPath = join(rig.testDir!, 'before_agent_context.cjs').replace( + /\\/g, + '/', + ); writeFileSync(scriptPath, hookScript); await rig.setup('should inject additional context via BeforeAgent hook', { @@ -113,7 +116,10 @@ describe('Hooks Agent Flow', () => { } `; - const scriptPath = join(rig.testDir!, 'after_agent_verify.cjs'); + const scriptPath = join(rig.testDir!, 'after_agent_verify.cjs').replace( + /\\/g, + '/', + ); writeFileSync(scriptPath, hookScript); await rig.setup('should receive prompt and response in AfterAgent hook', { @@ -165,7 +171,10 @@ describe('Hooks Agent Flow', () => { }); // BeforeModel hook to track message counts across LLM calls - const messageCountFile = join(rig.testDir!, 'message-counts.json'); + const messageCountFile = join( + rig.testDir!, + 'message-counts.json', + ).replace(/\\/g, '/'); const beforeModelScript = ` const fs = require('fs'); const input = JSON.parse(fs.readFileSync(0, 'utf-8')); @@ -179,13 +188,31 @@ describe('Hooks Agent Flow', () => { const beforeModelScriptPath = join( rig.testDir!, 'before_model_counter.cjs', - ); + ).replace(/\\/g, '/'); writeFileSync(beforeModelScriptPath, beforeModelScript); + const afterAgentScript = ` + console.log(JSON.stringify({ + decision: 'block', + reason: 'Security policy triggered', + hookSpecificOutput: { + hookEventName: 'AfterAgent', + clearContext: true + } + })); + `; + const afterAgentScriptPath = join( + rig.testDir!, + 'after_agent_clear.cjs', + ).replace(/\\/g, '/'); + writeFileSync(afterAgentScriptPath, afterAgentScript); + await rig.setup('should process clearContext in AfterAgent hook output', { settings: { - hooks: { + hooksConfig: { enabled: true, + }, + hooks: { BeforeModel: [ { hooks: [ @@ -202,7 +229,7 @@ describe('Hooks Agent Flow', () => { hooks: [ { type: 'command', - command: `node -e "console.log(JSON.stringify({decision: 'block', reason: 'Security policy triggered', hookSpecificOutput: {hookEventName: 'AfterAgent', clearContext: true}}))"`, + command: `node "${afterAgentScriptPath}"`, timeout: 5000, }, ], @@ -237,6 +264,24 @@ describe('Hooks Agent Flow', () => { describe('Multi-step Loops', () => { it('should fire BeforeAgent and AfterAgent exactly once per turn despite tool calls', async () => { + await rig.setup( + 'should fire BeforeAgent and AfterAgent exactly once per turn despite tool calls', + ); + + const beforeAgentScript = "console.log('BeforeAgent Fired')"; + const beforeAgentScriptPath = join( + rig.testDir!, + 'before_agent_loop.cjs', + ).replace(/\\/g, '/'); + writeFileSync(beforeAgentScriptPath, beforeAgentScript); + + const afterAgentScript = "console.log('AfterAgent Fired')"; + const afterAgentScriptPath = join( + rig.testDir!, + 'after_agent_loop.cjs', + ).replace(/\\/g, '/'); + writeFileSync(afterAgentScriptPath, afterAgentScript); + await rig.setup( 'should fire BeforeAgent and AfterAgent exactly once per turn despite tool calls', { @@ -254,7 +299,7 @@ describe('Hooks Agent Flow', () => { hooks: [ { type: 'command', - command: `node -e "console.log('BeforeAgent Fired')"`, + command: `node "${beforeAgentScriptPath}"`, timeout: 5000, }, ], @@ -265,7 +310,7 @@ describe('Hooks Agent Flow', () => { hooks: [ { type: 'command', - command: `node -e "console.log('AfterAgent Fired')"`, + command: `node "${afterAgentScriptPath}"`, timeout: 5000, }, ], diff --git a/integration-tests/hooks-system.test.ts b/integration-tests/hooks-system.test.ts index 9699916ade..dcaa11464b 100644 --- a/integration-tests/hooks-system.test.ts +++ b/integration-tests/hooks-system.test.ts @@ -24,6 +24,13 @@ describe('Hooks System Integration', () => { describe('Command Hooks - Blocking Behavior', () => { it('should block tool execution when hook returns block decision', async () => { + rig.setup('should block tool execution when hook returns block decision'); + const scriptPath = join(rig.testDir!, 'block_tool.cjs'); + writeFileSync( + scriptPath, + "console.log(JSON.stringify({decision: 'block', reason: 'File writing blocked by security policy'}))", + ); + rig.setup( 'should block tool execution when hook returns block decision', { @@ -43,8 +50,7 @@ describe('Hooks System Integration', () => { hooks: [ { type: 'command', - command: - "node -e \"console.log(JSON.stringify({decision: 'block', reason: 'File writing blocked by security policy'}))\"", + command: `node "${scriptPath}"`, timeout: 5000, }, ], @@ -78,6 +84,15 @@ describe('Hooks System Integration', () => { }); it('should block tool execution and use stderr as reason when hook exits with code 2', async () => { + rig.setup( + 'should block tool execution and use stderr as reason when hook exits with code 2', + ); + const scriptPath = join(rig.testDir!, 'block_tool_stderr.cjs'); + writeFileSync( + scriptPath, + "process.stderr.write('File writing blocked by security policy'); process.exit(2)", + ); + rig.setup( 'should block tool execution and use stderr as reason when hook exits with code 2', { @@ -97,8 +112,7 @@ describe('Hooks System Integration', () => { { type: 'command', // Exit with code 2 and write reason to stderr - command: - 'node -e "process.stderr.write(\'File writing blocked by security policy\'); process.exit(2)"', + command: `node "${scriptPath}"`, timeout: 5000, }, ], @@ -137,6 +151,13 @@ describe('Hooks System Integration', () => { }); it('should allow tool execution when hook returns allow decision', async () => { + rig.setup('should allow tool execution when hook returns allow decision'); + const scriptPath = join(rig.testDir!, 'allow_tool.cjs'); + writeFileSync( + scriptPath, + "console.log(JSON.stringify({decision: 'allow', reason: 'File writing approved'}))", + ); + rig.setup( 'should allow tool execution when hook returns allow decision', { @@ -155,8 +176,7 @@ describe('Hooks System Integration', () => { hooks: [ { type: 'command', - command: - "node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'File writing approved'}))\"", + command: `node "${scriptPath}"`, timeout: 5000, }, ], @@ -187,8 +207,13 @@ describe('Hooks System Integration', () => { describe('Command Hooks - Additional Context', () => { it('should add additional context from AfterTool hooks', async () => { - const command = - "node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'Security scan: File content appears safe'}}))\""; + rig.setup('should add additional context from AfterTool hooks'); + const scriptPath = join(rig.testDir!, 'after_tool_context.cjs'); + writeFileSync( + scriptPath, + "console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'Security scan: File content appears safe'}}))", + ); + const command = `node "${scriptPath}"`; rig.setup('should add additional context from AfterTool hooks', { fakeResponsesPath: join( import.meta.dirname, @@ -279,7 +304,7 @@ console.log(JSON.stringify({ hooks: [ { type: 'command', - command: `node "${scriptPath}"`, + command: `node "${scriptPath.replace(/\\/g, '/')}"`, timeout: 5000, }, ], @@ -577,7 +602,7 @@ console.log(JSON.stringify({ hooks: [ { type: 'command', - command: `node "${scriptPath}"`, + command: `node "${scriptPath.replace(/\\/g, '/')}"`, timeout: 5000, }, ], @@ -600,10 +625,14 @@ console.log(JSON.stringify({ describe('Notification Hooks - Permission Handling', () => { it('should handle notification hooks for tool permissions', async () => { - // Create inline hook command (works on both Unix and Windows) - // Create inline hook command (works on both Unix and Windows) - const hookCommand = - 'node -e "console.log(JSON.stringify({suppressOutput: false, systemMessage: \'Permission request logged by security hook\'}))"'; + rig.setup('should handle notification hooks for tool permissions'); + // Create script for hook (works on both Unix and Windows) + const scriptPath = join(rig.testDir!, 'notification_hook.cjs'); + writeFileSync( + scriptPath, + "console.log(JSON.stringify({suppressOutput: false, systemMessage: 'Permission request logged by security hook'}))", + ); + const hookCommand = `node "${scriptPath.replace(/\\/g, '/')}"`; rig.setup('should handle notification hooks for tool permissions', { fakeResponsesPath: join( @@ -697,11 +726,21 @@ console.log(JSON.stringify({ describe('Sequential Hook Execution', () => { it('should execute hooks sequentially when configured', async () => { - // Create inline hook commands (works on both Unix and Windows) - const hook1Command = - "node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 1: Initial validation passed.'}}))\""; - const hook2Command = - "node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 2: Security check completed.'}}))\""; + rig.setup('should execute hooks sequentially when configured'); + // Create script for hooks (works on both Unix and Windows) + const script1Path = join(rig.testDir!, 'hook1.cjs'); + writeFileSync( + script1Path, + "console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 1: Initial validation passed.'}}))", + ); + const script2Path = join(rig.testDir!, 'hook2.cjs'); + writeFileSync( + script2Path, + "console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 2: Security check completed.'}}))", + ); + + const hook1Command = `node "${script1Path.replace(/\\/g, '/')}"`; + const hook2Command = `node "${script2Path.replace(/\\/g, '/')}"`; rig.setup('should execute hooks sequentially when configured', { fakeResponsesPath: join( @@ -800,7 +839,7 @@ try { hooks: [ { type: 'command', - command: `node "${scriptPath}"`, + command: `node "${scriptPath.replace(/\\/g, '/')}"`, timeout: 5000, }, ], @@ -828,6 +867,15 @@ try { }); it('should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', async () => { + rig.setup( + 'should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', + ); + const scriptPath = join(rig.testDir!, 'mixed_stdout.cjs'); + writeFileSync( + scriptPath, + "console.log('Pollution'); console.log(JSON.stringify({decision: 'deny', reason: 'Should be ignored'}))", + ); + rig.setup( 'should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', { @@ -848,8 +896,7 @@ try { type: 'command', // Output plain text then JSON. // This breaks JSON parsing, so it falls back to 'allow' with the whole stdout as systemMessage. - command: - "node -e \"console.log('Pollution'); console.log(JSON.stringify({decision: 'deny', reason: 'Should be ignored'}))\"", + command: `node "${scriptPath}"`, timeout: 5000, }, ], @@ -876,13 +923,27 @@ try { describe('Multiple Event Types', () => { it('should handle hooks for all major event types', async () => { - // Create inline hook commands (works on both Unix and Windows) - const beforeToolCommand = - "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'BeforeTool: File operation logged'}))\""; - const afterToolCommand = - "node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'AfterTool: Operation completed successfully'}}))\""; - const beforeAgentCommand = - "node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'BeforeAgent: User request processed'}}))\""; + rig.setup('should handle hooks for all major event types'); + // Create scripts for hooks (works on both Unix and Windows) + const beforeToolScript = join(rig.testDir!, 'before_tool_all.cjs'); + writeFileSync( + beforeToolScript, + "console.log(JSON.stringify({decision: 'allow', systemMessage: 'BeforeTool: File operation logged'}))", + ); + const afterToolScript = join(rig.testDir!, 'after_tool_all.cjs'); + writeFileSync( + afterToolScript, + "console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'AfterTool: Operation completed successfully'}}))", + ); + const beforeAgentScript = join(rig.testDir!, 'before_agent_all.cjs'); + writeFileSync( + beforeAgentScript, + "console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'BeforeAgent: User request processed'}}))", + ); + + const beforeToolCommand = `node "${beforeToolScript.replace(/\\/g, '/')}"`; + const afterToolCommand = `node "${afterToolScript.replace(/\\/g, '/')}"`; + const beforeAgentCommand = `node "${beforeAgentScript.replace(/\\/g, '/')}"`; rig.setup('should handle hooks for all major event types', { fakeResponsesPath: join( @@ -989,21 +1050,23 @@ try { describe('Hook Error Handling', () => { it('should handle hook failures gracefully', async () => { + rig.setup('should handle hook failures gracefully'); + const failingScript = join(rig.testDir!, 'failing_hook.cjs'); + writeFileSync(failingScript, 'process.exit(1)'); + const workingScript = join(rig.testDir!, 'working_hook.cjs'); + writeFileSync( + workingScript, + "console.log(JSON.stringify({decision: 'allow', reason: 'Working hook succeeded'}))", + ); + + const failingCommand = `node "${failingScript.replace(/\\/g, '/')}"`; + const workingCommand = `node "${workingScript.replace(/\\/g, '/')}"`; + rig.setup('should handle hook failures gracefully', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.error-handling.responses', ), - }); - // Create a hook script that fails - // Create inline hook commands (works on both Unix and Windows) - // Failing hook: exits with non-zero code - const failingCommand = 'node -e "process.exit(1)"'; - // Working hook: returns success with JSON - const workingCommand = - "node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Working hook succeeded'}))\""; - - rig.setup('should handle hook failures gracefully', { settings: { hooksConfig: { enabled: true, @@ -1049,9 +1112,13 @@ try { describe('Hook Telemetry and Observability', () => { it('should generate telemetry events for hook executions', async () => { - // Create inline hook command (works on both Unix and Windows) - const hookCommand = - "node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Telemetry test hook'}))\""; + rig.setup('should generate telemetry events for hook executions'); + const scriptPath = join(rig.testDir!, 'telemetry_hook.cjs'); + writeFileSync( + scriptPath, + "console.log(JSON.stringify({decision: 'allow', reason: 'Telemetry test hook'}))", + ); + const hookCommand = `node "${scriptPath.replace(/\\/g, '/')}"`; rig.setup('should generate telemetry events for hook executions', { fakeResponsesPath: join( @@ -1092,9 +1159,13 @@ try { describe('Session Lifecycle Hooks', () => { it('should fire SessionStart hook on app startup', async () => { - // Create inline hook command that outputs JSON - const sessionStartCommand = - "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting on startup'}))\""; + rig.setup('should fire SessionStart hook on app startup'); + const scriptPath = join(rig.testDir!, 'session_start.cjs'); + writeFileSync( + scriptPath, + "console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting on startup'}))", + ); + const sessionStartCommand = `node "${scriptPath.replace(/\\/g, '/')}"`; rig.setup('should fire SessionStart hook on app startup', { fakeResponsesPath: join( @@ -1185,7 +1256,7 @@ console.log(JSON.stringify({ hooks: [ { type: 'command', - command: `node "${scriptPath}"`, + command: `node "${scriptPath.replace(/\\/g, '/')}"`, timeout: 5000, }, ], @@ -1316,11 +1387,23 @@ console.log(JSON.stringify({ }); it('should fire SessionEnd and SessionStart hooks on /clear command', async () => { - // Create inline hook commands for both SessionEnd and SessionStart - const sessionEndCommand = - "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session ending due to clear'}))\""; - const sessionStartCommand = - "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting after clear'}))\""; + rig.setup( + 'should fire SessionEnd and SessionStart hooks on /clear command', + ); + // Create script for hooks (works on both Unix and Windows) + const endScriptPath = join(rig.testDir!, 'session_end_clear.cjs'); + writeFileSync( + endScriptPath, + "console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session ending due to clear'}))", + ); + const startScriptPath = join(rig.testDir!, 'session_start_clear.cjs'); + writeFileSync( + startScriptPath, + "console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting after clear'}))", + ); + + const sessionEndCommand = `node "${endScriptPath.replace(/\\/g, '/')}"`; + const sessionStartCommand = `node "${startScriptPath.replace(/\\/g, '/')}"`; rig.setup( 'should fire SessionEnd and SessionStart hooks on /clear command', @@ -1494,9 +1577,13 @@ console.log(JSON.stringify({ describe('Compression Hooks', () => { it('should fire PreCompress hook on automatic compression', async () => { - // Create inline hook command that outputs JSON - const preCompressCommand = - "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'PreCompress hook executed for automatic compression'}))\""; + rig.setup('should fire PreCompress hook on automatic compression'); + const scriptPath = join(rig.testDir!, 'pre_compress.cjs'); + writeFileSync( + scriptPath, + "console.log(JSON.stringify({decision: 'allow', systemMessage: 'PreCompress hook executed for automatic compression'}))", + ); + const preCompressCommand = `node "${scriptPath.replace(/\\/g, '/')}"`; rig.setup('should fire PreCompress hook on automatic compression', { fakeResponsesPath: join( @@ -1562,8 +1649,15 @@ console.log(JSON.stringify({ describe('SessionEnd on Exit', () => { it('should fire SessionEnd hook on graceful exit in non-interactive mode', async () => { - const sessionEndCommand = - "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'SessionEnd hook executed on exit'}))\""; + rig.setup( + 'should fire SessionEnd hook on graceful exit in non-interactive mode', + ); + const scriptPath = join(rig.testDir!, 'session_end_exit.cjs'); + writeFileSync( + scriptPath, + "console.log(JSON.stringify({decision: 'allow', systemMessage: 'SessionEnd hook executed on exit'}))", + ); + const sessionEndCommand = `node "${scriptPath.replace(/\\/g, '/')}"`; rig.setup('should fire SessionEnd hook on graceful exit', { fakeResponsesPath: join(