diff --git a/integration-tests/hooks-system.test.ts b/integration-tests/hooks-system.test.ts index c353f98511..0f92a16c43 100644 --- a/integration-tests/hooks-system.test.ts +++ b/integration-tests/hooks-system.test.ts @@ -24,7 +24,7 @@ describe('Hooks System Integration', () => { describe('Command Hooks - Blocking Behavior', () => { it('should block tool execution when hook returns block decision', async () => { - await rig.setup( + rig.setup( 'should block tool execution when hook returns block decision', { fakeResponsesPath: join( @@ -75,8 +75,65 @@ describe('Hooks System Integration', () => { expect(hookTelemetryFound).toBeTruthy(); }); + 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', + { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.block-tool.responses', + ), + settings: { + hooks: { + enabled: true, + BeforeTool: [ + { + matcher: 'write_file', + hooks: [ + { + 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)"', + timeout: 5000, + }, + ], + }, + ], + }, + }, + }, + ); + + const result = await rig.run({ + args: 'Create a file called test.txt with content "Hello World"', + }); + + // The hook should block the write_file tool + const toolLogs = rig.readToolLogs(); + const writeFileCalls = toolLogs.filter( + (t) => + t.toolRequest.name === 'write_file' && t.toolRequest.success === true, + ); + + // Tool should not be called due to blocking hook + expect(writeFileCalls).toHaveLength(0); + + // Result should mention the blocking reason from stderr + expect(result).toContain('File writing blocked by security policy'); + + // Verify hook telemetry shows exit code 2 and stderr + const hookLogs = rig.readHookLogs(); + const blockHook = hookLogs.find((log) => log.hookCall.exit_code === 2); + expect(blockHook).toBeDefined(); + expect(blockHook?.hookCall.stderr).toContain( + 'File writing blocked by security policy', + ); + expect(blockHook?.hookCall.success).toBe(false); + }); + it('should allow tool execution when hook returns allow decision', async () => { - await rig.setup( + rig.setup( 'should allow tool execution when hook returns allow decision', { fakeResponsesPath: join( @@ -126,7 +183,7 @@ describe('Hooks System Integration', () => { 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'}}))\""; - await rig.setup('should add additional context from AfterTool hooks', { + rig.setup('should add additional context from AfterTool hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.after-tool-context.responses', @@ -178,7 +235,7 @@ describe('Hooks System Integration', () => { it('should modify LLM requests with BeforeModel hooks', async () => { // Create a hook script that replaces the LLM request with a modified version // Note: Providing messages in the hook output REPLACES the entire conversation - await rig.setup('should modify LLM requests with BeforeModel hooks', { + rig.setup('should modify LLM requests with BeforeModel hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.before-model.responses', @@ -203,7 +260,7 @@ console.log(JSON.stringify({ const scriptPath = join(rig.testDir!, 'before_model_hook.cjs'); writeFileSync(scriptPath, hookScript); - await rig.setup('should modify LLM requests with BeforeModel hooks', { + rig.setup('should modify LLM requests with BeforeModel hooks', { settings: { hooks: { enabled: true, @@ -248,13 +305,99 @@ console.log(JSON.stringify({ expect(hookTelemetryFound[0].hookCall.stdout).toBeDefined(); expect(hookTelemetryFound[0].hookCall.stderr).toBeDefined(); }); + + it('should block model execution when BeforeModel hook returns deny decision', async () => { + rig.setup( + 'should block model execution when BeforeModel hook returns deny decision', + ); + const hookScript = `console.log(JSON.stringify({ + decision: "deny", + reason: "Model execution blocked by security policy" +}));`; + const scriptPath = join(rig.testDir!, 'before_model_deny_hook.cjs'); + writeFileSync(scriptPath, hookScript); + + rig.setup( + 'should block model execution when BeforeModel hook returns deny decision', + { + settings: { + hooks: { + enabled: true, + BeforeModel: [ + { + hooks: [ + { + type: 'command', + command: `node "${scriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }, + ); + + const result = await rig.run({ args: 'Hello' }); + + // The hook should have blocked the request + expect(result).toContain('Model execution blocked by security policy'); + + // Verify no API requests were made to the LLM + const apiRequests = rig.readAllApiRequest(); + expect(apiRequests).toHaveLength(0); + }); + + it('should block model execution when BeforeModel hook returns block decision', async () => { + rig.setup( + 'should block model execution when BeforeModel hook returns block decision', + ); + const hookScript = `console.log(JSON.stringify({ + decision: "block", + reason: "Model execution blocked by security policy" +}));`; + const scriptPath = join(rig.testDir!, 'before_model_block_hook.cjs'); + writeFileSync(scriptPath, hookScript); + + rig.setup( + 'should block model execution when BeforeModel hook returns block decision', + { + settings: { + hooks: { + enabled: true, + BeforeModel: [ + { + hooks: [ + { + type: 'command', + command: `node "${scriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }, + ); + + const result = await rig.run({ args: 'Hello' }); + + // The hook should have blocked the request + expect(result).toContain('Model execution blocked by security policy'); + + // Verify no API requests were made to the LLM + const apiRequests = rig.readAllApiRequest(); + expect(apiRequests).toHaveLength(0); + }); }); describe('AfterModel Hooks - LLM Response Modification', () => { it.skipIf(process.platform === 'win32')( 'should modify LLM responses with AfterModel hooks', async () => { - await rig.setup('should modify LLM responses with AfterModel hooks', { + rig.setup('should modify LLM responses with AfterModel hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.after-model.responses', @@ -284,7 +427,7 @@ console.log(JSON.stringify({ const scriptPath = join(rig.testDir!, 'after_model_hook.cjs'); writeFileSync(scriptPath, hookScript); - await rig.setup('should modify LLM responses with AfterModel hooks', { + rig.setup('should modify LLM responses with AfterModel hooks', { settings: { hooks: { enabled: true, @@ -319,41 +462,35 @@ console.log(JSON.stringify({ describe('BeforeToolSelection Hooks - Tool Configuration', () => { it('should modify tool selection with BeforeToolSelection hooks', async () => { - await rig.setup( - 'should modify tool selection with BeforeToolSelection hooks', - { - fakeResponsesPath: join( - import.meta.dirname, - 'hooks-system.before-tool-selection.responses', - ), - }, - ); + rig.setup('should modify tool selection with BeforeToolSelection hooks', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.before-tool-selection.responses', + ), + }); // Create inline hook command (works on both Unix and Windows) const hookCommand = "node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'BeforeToolSelection', toolConfig: {mode: 'ANY', allowedFunctionNames: ['read_file', 'run_shell_command']}}}))\""; - await rig.setup( - 'should modify tool selection with BeforeToolSelection hooks', - { - settings: { - debugMode: true, - hooks: { - enabled: true, - BeforeToolSelection: [ - { - hooks: [ - { - type: 'command', - command: hookCommand, - timeout: 5000, - }, - ], - }, - ], - }, + rig.setup('should modify tool selection with BeforeToolSelection hooks', { + settings: { + debugMode: true, + hooks: { + enabled: true, + BeforeToolSelection: [ + { + hooks: [ + { + type: 'command', + command: hookCommand, + timeout: 5000, + }, + ], + }, + ], }, }, - ); + }); // Create a test file rig.createFile('new_file_data.txt', 'test data'); @@ -382,7 +519,7 @@ console.log(JSON.stringify({ describe('BeforeAgent Hooks - Prompt Augmentation', () => { it('should augment prompts with BeforeAgent hooks', async () => { - await rig.setup('should augment prompts with BeforeAgent hooks', { + rig.setup('should augment prompts with BeforeAgent hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.before-agent.responses', @@ -401,7 +538,7 @@ console.log(JSON.stringify({ const scriptPath = join(rig.testDir!, 'before_agent_hook.cjs'); writeFileSync(scriptPath, hookScript); - await rig.setup('should augment prompts with BeforeAgent hooks', { + rig.setup('should augment prompts with BeforeAgent hooks', { settings: { hooks: { enabled: true, @@ -438,7 +575,7 @@ console.log(JSON.stringify({ const hookCommand = 'node -e "console.log(JSON.stringify({suppressOutput: false, systemMessage: \'Permission request logged by security hook\'}))"'; - await rig.setup('should handle notification hooks for tool permissions', { + rig.setup('should handle notification hooks for tool permissions', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.notification.responses', @@ -534,7 +671,7 @@ console.log(JSON.stringify({ const hook2Command = "node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 2: Security check completed.'}}))\""; - await rig.setup('should execute hooks sequentially when configured', { + rig.setup('should execute hooks sequentially when configured', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.sequential-execution.responses', @@ -594,7 +731,7 @@ console.log(JSON.stringify({ describe('Hook Input/Output Validation', () => { it('should provide correct input format to hooks', async () => { - await rig.setup('should provide correct input format to hooks', { + rig.setup('should provide correct input format to hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.input-validation.responses', @@ -618,7 +755,7 @@ try { const scriptPath = join(rig.testDir!, 'input_validation_hook.cjs'); writeFileSync(scriptPath, hookScript); - await rig.setup('should provide correct input format to hooks', { + rig.setup('should provide correct input format to hooks', { settings: { hooks: { enabled: true, @@ -653,6 +790,50 @@ try { const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); }); + + 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', + { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.allow-tool.responses', + ), + settings: { + hooks: { + enabled: true, + BeforeTool: [ + { + matcher: 'write_file', + hooks: [ + { + 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'}))\"", + timeout: 5000, + }, + ], + }, + ], + }, + }, + }, + ); + + const result = await rig.run({ + args: 'Create a file called approved.txt with content "Approved content"', + }); + + // The hook logic fails to parse JSON, so it allows the tool. + const foundWriteFile = await rig.waitForToolCall('write_file'); + expect(foundWriteFile).toBeTruthy(); + + // The entire stdout (including the JSON part) becomes the systemMessage + expect(result).toContain('Pollution'); + expect(result).toContain('Should be ignored'); + }); }); describe('Multiple Event Types', () => { @@ -665,7 +846,7 @@ try { const beforeAgentCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'BeforeAgent: User request processed'}}))\""; - await rig.setup('should handle hooks for all major event types', { + rig.setup('should handle hooks for all major event types', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.multiple-events.responses', @@ -768,7 +949,7 @@ try { describe('Hook Error Handling', () => { it('should handle hook failures gracefully', async () => { - await rig.setup('should handle hook failures gracefully', { + rig.setup('should handle hook failures gracefully', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.error-handling.responses', @@ -782,7 +963,7 @@ try { const workingCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Working hook succeeded'}))\""; - await rig.setup('should handle hook failures gracefully', { + rig.setup('should handle hook failures gracefully', { settings: { hooks: { enabled: true, @@ -830,7 +1011,7 @@ try { const hookCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Telemetry test hook'}))\""; - await rig.setup('should generate telemetry events for hook executions', { + rig.setup('should generate telemetry events for hook executions', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.telemetry.responses', @@ -871,7 +1052,7 @@ try { const sessionStartCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting on startup'}))\""; - await rig.setup('should fire SessionStart hook on app startup', { + rig.setup('should fire SessionStart hook on app startup', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.session-startup.responses', @@ -936,7 +1117,7 @@ console.log(JSON.stringify({ } }));`; - await rig.setup('should fire SessionStart hook and inject context', { + rig.setup('should fire SessionStart hook and inject context', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.session-startup.responses', @@ -946,7 +1127,7 @@ console.log(JSON.stringify({ const scriptPath = join(rig.testDir!, 'session_start_context_hook.cjs'); writeFileSync(scriptPath, hookScript); - await rig.setup('should fire SessionStart hook and inject context', { + rig.setup('should fire SessionStart hook and inject context', { settings: { hooks: { enabled: true, @@ -1011,7 +1192,7 @@ console.log(JSON.stringify({ } }));`; - await rig.setup( + rig.setup( 'should fire SessionStart hook and display systemMessage in interactive mode', { fakeResponsesPath: join( @@ -1027,7 +1208,7 @@ console.log(JSON.stringify({ ); writeFileSync(scriptPath, hookScript); - await rig.setup( + rig.setup( 'should fire SessionStart hook and display systemMessage in interactive mode', { settings: { @@ -1091,7 +1272,7 @@ console.log(JSON.stringify({ const sessionStartCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting after clear'}))\""; - await rig.setup( + rig.setup( 'should fire SessionEnd and SessionStart hooks on /clear command', { fakeResponsesPath: join( @@ -1265,7 +1446,7 @@ console.log(JSON.stringify({ const preCompressCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'PreCompress hook executed for automatic compression'}))\""; - await rig.setup('should fire PreCompress hook on automatic compression', { + rig.setup('should fire PreCompress hook on automatic compression', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.compress-auto.responses', @@ -1330,7 +1511,7 @@ console.log(JSON.stringify({ const sessionEndCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'SessionEnd hook executed on exit'}))\""; - await rig.setup('should fire SessionEnd hook on graceful exit', { + rig.setup('should fire SessionEnd hook on graceful exit', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.session-startup.responses', @@ -1412,7 +1593,7 @@ console.log(JSON.stringify({ describe('Hook Disabling', () => { it('should not execute hooks disabled in settings file', async () => { - await rig.setup('should not execute hooks disabled in settings file', { + rig.setup('should not execute hooks disabled in settings file', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.disabled-via-settings.responses', @@ -1432,7 +1613,7 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho writeFileSync(enabledPath, enabledHookScript); writeFileSync(disabledPath, disabledHookScript); - await rig.setup('should not execute hooks disabled in settings file', { + rig.setup('should not execute hooks disabled in settings file', { settings: { hooks: { enabled: true, @@ -1487,15 +1668,12 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho }); it('should respect disabled hooks across multiple operations', async () => { - await rig.setup( - 'should respect disabled hooks across multiple operations', - { - fakeResponsesPath: join( - import.meta.dirname, - 'hooks-system.disabled-via-command.responses', - ), - }, - ); + rig.setup('should respect disabled hooks across multiple operations', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.disabled-via-command.responses', + ), + }); // Create two hook scripts - one that will be disabled, one that won't const activeHookScript = `const fs = require('fs'); @@ -1510,33 +1688,30 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho writeFileSync(activePath, activeHookScript); writeFileSync(disabledPath, disabledHookScript); - await rig.setup( - 'should respect disabled hooks across multiple operations', - { - settings: { - hooks: { - enabled: true, - BeforeTool: [ - { - hooks: [ - { - type: 'command', - command: `node "${activePath}"`, - timeout: 5000, - }, - { - type: 'command', - command: `node "${disabledPath}"`, - timeout: 5000, - }, - ], - }, - ], - disabled: [`node "${disabledPath}"`], // Disable the second hook - }, + rig.setup('should respect disabled hooks across multiple operations', { + settings: { + hooks: { + enabled: true, + BeforeTool: [ + { + hooks: [ + { + type: 'command', + command: `node "${activePath}"`, + timeout: 5000, + }, + { + type: 'command', + command: `node "${disabledPath}"`, + timeout: 5000, + }, + ], + }, + ], + disabled: [`node "${disabledPath}"`], // Disable the second hook }, }, - ); + }); // First run - only active hook should execute const result1 = await rig.run({ @@ -1587,9 +1762,7 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho describe('BeforeTool Hooks - Input Override', () => { it('should override tool input parameters via BeforeTool hook', async () => { // 1. First setup to get the test directory and prepare the hook script - await rig.setup( - 'should override tool input parameters via BeforeTool hook', - ); + rig.setup('should override tool input parameters via BeforeTool hook'); // Create a hook script that overrides the tool input const hookOutput = { @@ -1616,32 +1789,29 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho const commandPath = scriptPath.replace(/\\/g, '/'); // 2. Full setup with settings and fake responses - await rig.setup( - 'should override tool input parameters via BeforeTool hook', - { - fakeResponsesPath: join( - import.meta.dirname, - 'hooks-system.input-modification.responses', - ), - settings: { - hooks: { - enabled: true, - BeforeTool: [ - { - matcher: 'write_file', - hooks: [ - { - type: 'command', - command: `node "${commandPath}"`, - timeout: 5000, - }, - ], - }, - ], - }, + rig.setup('should override tool input parameters via BeforeTool hook', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.input-modification.responses', + ), + settings: { + hooks: { + enabled: true, + BeforeTool: [ + { + matcher: 'write_file', + hooks: [ + { + type: 'command', + command: `node "${commandPath}"`, + timeout: 5000, + }, + ], + }, + ], }, }, - ); + }); // Run the agent. The fake response will attempt to call write_file with // file_path="original.txt" and content="original content" @@ -1698,12 +1868,12 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho hookOutput, )}));`; - await rig.setup('should stop agent execution via BeforeTool hook'); + rig.setup('should stop agent execution via BeforeTool hook'); const scriptPath = join(rig.testDir!, 'before_tool_stop_hook.js'); writeFileSync(scriptPath, hookScript); const commandPath = scriptPath.replace(/\\/g, '/'); - await rig.setup('should stop agent execution via BeforeTool hook', { + rig.setup('should stop agent execution via BeforeTool hook', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.before-tool-stop.responses',