qwen-code/integration-tests/hook-integration/hooks.test.ts
2026-03-10 00:29:08 -07:00

4114 lines
131 KiB
TypeScript

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
* - SessionStart hooks: Triggered when a new session starts (Startup, Resume, Clear, Compact)
* - SessionEnd hooks: Triggered when a session ends (Clear, Logout, PromptInputExit)
*
* Test categories:
* - Single hook scenarios (allow, block, modify, context, etc.)
* - Multiple hooks scenarios (parallel, sequential, mixed)
* - Error handling (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 =
'echo \'{"decision": "allow", "reason": "approved by hook"}\'';
await rig.setup('ups-allow-decision', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 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 =
'echo \'{"decision": "allow", "reason": "Tool execution approved"}\'';
await rig.setup('ups-allow-tool', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 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 =
'echo \'{"decision": "block", "reason": "Prompt blocked by security policy"}\'';
await rig.setup('ups-block-decision', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: blockScript,
name: 'ups-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When UserPromptSubmit hook blocks, CLI exits with non-zero code
// and rig.run() throws an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should block tool execution when hook returns block and verify no tool was called', async () => {
const blockScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "File writing blocked by security policy"}\'';
await rig.setup('ups-block-tool', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: blockScript,
name: 'ups-block-tool-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When UserPromptSubmit hook blocks, CLI exits with non-zero code
await expect(
rig.run('Create a file test.txt with "hello"'),
).rejects.toThrow(/block/i);
// Tool should not be called due to blocking hook
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(0); // At least one hook call occurred
const toolLogs = rig.readToolLogs();
const writeFileCalls = toolLogs.filter(
(t) =>
t.toolRequest.name === 'write_file' &&
t.toolRequest.success === true,
);
expect(writeFileCalls).toHaveLength(0);
});
});
describe('Modify Prompt', () => {
it('should use modified prompt when hook provides modification', async () => {
const modifyScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "modifiedPrompt": "Modified prompt content", "additionalContext": "Context added by hook"}}\'';
await rig.setup('ups-modify-prompt', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 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 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Extra context information from hook"}}\'';
await rig.setup('ups-add-context', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 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: {
hooksConfig: { enabled: true },
hooks: {
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: {
hooksConfig: { enabled: true },
hooks: {
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: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 'echo "Critical security error" >&2 && exit 2',
name: 'ups-blocking-error-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// Exit code 2 is a blocking error, so CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should continue execution when hook command is empty', async () => {
await rig.setup('ups-missing-command', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: '',
name: 'ups-missing-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// Empty command is ignored, execution continues normally
const result = await rig.run('Say missing test');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Input Format Validation', () => {
it('should receive properly formatted input when hook is called', async () => {
const inputValidationScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "Valid input format"}}\'';
await rig.setup('ups-correct-input', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: inputValidationScript,
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 =
'echo \'{"decision": "allow", "systemMessage": "This is a system message from hook"}\'';
await rig.setup('ups-system-message', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 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 =
'echo \'{"decision": "allow", "reason": "Allowed"}\'';
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked by security policy"}\'';
await rig.setup('ups-multi-one-blocks', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'ups-allow-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'ups-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When any hook blocks, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should block when first sequential hook returns block', async () => {
// Note: Sequential hooks execute ALL hooks before aggregating results.
// Even if the first hook returns block, the second hook still runs.
// The final aggregated result will be block if any hook returns block.
// For UserPromptSubmit, a block decision should cause CLI to throw an error.
const blockScript =
'echo \'{"decision": "block", "reason": "First hook blocks"}\'';
await rig.setup('ups-seq-first-blocks', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: blockScript,
name: 'ups-seq-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// Single sequential hook with block decision should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should block when second sequential hook returns block', async () => {
// Note: Sequential hooks execute ALL hooks before aggregating results.
// The first hook allows, but the second hook blocks.
// The final aggregated result will be block (OR logic: any block = block).
const allowScript =
'echo \'{"decision": "allow", "reason": "First allows"}\'';
const blockScript =
'echo \'{"decision": "block", "reason": "Second hook blocks"}\'';
await rig.setup('ups-seq-second-blocks', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: allowScript,
name: 'ups-seq-first-allow',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'ups-seq-second-block',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// Second hook blocks, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should handle multiple hooks all returning allow', async () => {
const allow1Script =
'echo \'{"decision": "allow", "reason": "First allows"}\'';
const allow2Script =
'echo \'{"decision": "allow", "reason": "Second allows"}\'';
const allow3Script =
'echo \'{"decision": "allow", "reason": "Third allows"}\'';
await rig.setup('ups-multi-all-allow', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: allow1Script,
name: 'ups-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allow2Script,
name: 'ups-allow-2',
timeout: 5000,
},
{
type: 'command',
command: 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 =
'echo \'{"decision": "block", "reason": "First blocks"}\'';
const block2Script =
'echo \'{"decision": "block", "reason": "Second blocks"}\'';
await rig.setup('ups-multi-all-block', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: block1Script,
name: 'ups-block-1',
timeout: 5000,
},
{
type: 'command',
command: block2Script,
name: 'ups-block-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// All hooks block, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should concatenate additional context from multiple hooks', async () => {
const context1Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context from hook 1"}}\'';
const context2Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context from hook 2"}}\'';
await rig.setup('ups-multi-context', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: context1Script,
name: 'ups-context-1',
timeout: 5000,
},
{
type: 'command',
command: 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 =
'echo \'{"decision": "block", "reason": "Blocked"}\'';
await rig.setup('ups-error-with-block', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/command',
name: 'ups-error-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'ups-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// Block should still work despite error in other hook, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should handle hook timeout alongside blocking hook', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked while other times out"}\'';
await rig.setup('ups-timeout-with-block', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 'sleep 60',
name: 'ups-timeout-hook',
timeout: 1000,
},
{
type: 'command',
command: blockScript,
name: 'ups-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// Block should work despite timeout in other hook, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should handle multiple hook groups with different configurations', async () => {
const allow1Script =
'echo \'{"decision": "allow", "reason": "Group 1 allows"}\'';
const allow2Script =
'echo \'{"decision": "allow", "reason": "Group 2 allows"}\'';
await rig.setup('ups-multi-groups', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: allow1Script,
name: 'ups-group1-hook',
timeout: 5000,
},
],
},
{
sequential: true,
hooks: [
{
type: 'command',
command: 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 =
'echo \'{"decision": "allow", "reason": "Group 1 allows"}\'';
const blockScript =
'echo \'{"decision": "block", "reason": "Group 2 blocks"}\'';
await rig.setup('ups-multi-groups-one-blocks', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'ups-group1-allow',
timeout: 5000,
},
],
},
{
hooks: [
{
type: 'command',
command: blockScript,
name: 'ups-group2-block',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// One group blocks, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should handle modified prompt from multiple hooks', async () => {
const modify1Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"modifiedPrompt": "Modified by hook 1"}}\'';
const modify2Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"modifiedPrompt": "Modified by hook 2"}}\'';
await rig.setup('ups-multi-modify', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: modify1Script,
name: 'ups-modify-1',
timeout: 5000,
},
{
type: 'command',
command: 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 =
'echo \'{"decision": "allow", "systemMessage": "System message 1"}\'';
const msg2Script =
'echo \'{"decision": "allow", "systemMessage": "System message 2"}\'';
await rig.setup('ups-multi-system-msg', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: msg1Script,
name: 'ups-msg-1',
timeout: 5000,
},
{
type: 'command',
command: 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 =
'echo \'{"decision": "allow", "reason": "Stop allowed"}\'';
await rig.setup('stop-allow', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 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 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Final context from stop hook"}}\'';
await rig.setup('stop-allow-final', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 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 continue execution when hook returns block decision', async () => {
// Stop hook's block decision means "block stopping" (i.e., force continuation)
// not "block operation and show error"
// Use background process to write count file, ensuring final output is pure JSON
const blockStopScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\'';
await rig.setup('stop-block-decision', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: blockStopScript,
name: 'stop-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop)
const result = await rig.run('Say hello', '--max-session-turns', '3');
// Verify that execution completed successfully (not blocked by Stop hook)
// Verify Stop hook was invoked multiple times (indicating multiple rounds)
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
const toolLogs = rig.readToolLogs();
const hasActivity = result.length > 0 || toolLogs.length > 0;
expect(hasActivity).toBe(true);
});
it('should continue execution with custom reason', async () => {
// Stop hook's block decision means "block stopping" (i.e., force continuation)
const blockReasonScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Custom block reason: task incomplete"}\'';
await rig.setup('stop-block-custom-reason', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: blockReasonScript,
name: 'stop-block-reason-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop)
const result = await rig.run('Say goodbye', '--max-session-turns', '3');
// Verify that execution completed successfully (not blocked by Stop hook)
// This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
const toolLogs = rig.readToolLogs();
const hasActivity = result.length > 0 || toolLogs.length > 0;
expect(hasActivity).toBe(true);
});
});
describe('Additional Context', () => {
it('should include additional context in final response', async () => {
const contextScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Final context from hook"}}\'';
await rig.setup('stop-add-context', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 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 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context1"}}\'';
const context2Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context2"}}\'';
await rig.setup('stop-multi-context', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: context1Script,
name: 'stop-context-1',
timeout: 5000,
},
{
type: 'command',
command: 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 =
'echo \'{"decision": "allow", "stopReason": "Custom stop reason from hook"}\'';
await rig.setup('stop-set-reason', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 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: {
hooksConfig: { enabled: true },
hooks: {
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: {
hooksConfig: { enabled: true },
hooks: {
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: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 'false',
name: 'stop-missing-hook',
timeout: 1000,
},
],
},
],
},
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 =
'echo \'{"decision": "allow", "systemMessage": "Final system message from stop hook"}\'';
await rig.setup('stop-system-message', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 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 continue execution when one of multiple parallel stop hooks returns block', async () => {
// Stop hook's block decision means "block stopping" (i.e., force continuation)
const allowScript =
'echo \'{"decision": "allow", "reason": "Stop allowed"}\'';
// Write to a file to count hook invocations, then echo the decision
const blockScript =
'echo "hook_called" >> hook_invoke_count.txt; echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\'';
await rig.setup('stop-multi-one-blocks', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'stop-allow-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'stop-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop)
const result = await rig.run(
'Say multi stop',
'--max-session-turns',
'3',
);
// Verify that execution completed successfully (not blocked by Stop hook)
// This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
const toolLogs = rig.readToolLogs();
const hasActivity = result.length > 0 || toolLogs.length > 0;
expect(hasActivity).toBe(true);
});
it('should continue execution when first sequential stop hook returns block', async () => {
// Stop hook's block decision means "block stopping" (i.e., force continuation)
const blockScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "First hook blocks stop"}\'';
const allowScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow", "reason": "This should still run"}\'';
await rig.setup('stop-seq-first-blocks', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
sequential: true,
hooks: [
{
type: 'command',
command: blockScript,
name: 'stop-seq-block-hook',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'stop-seq-allow-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop)
const result = await rig.run(
'Say sequential stop',
'--max-session-turns',
'3',
);
// Verify that execution completed successfully (not blocked by Stop hook)
// This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
const toolLogs = rig.readToolLogs();
const hasActivity = result.length > 0 || toolLogs.length > 0;
expect(hasActivity).toBe(true);
});
it('should continue execution when second sequential stop hook returns block', async () => {
// Stop hook's block decision means "block stopping" (i.e., force continuation)
const allowScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow", "reason": "First allows"}\'';
const blockScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Second hook blocks stop"}\'';
await rig.setup('stop-seq-second-blocks', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
sequential: true,
hooks: [
{
type: 'command',
command: allowScript,
name: 'stop-seq-first-allow',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'stop-seq-second-block',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop)
const result = await rig.run(
'Say seq second blocks',
'--max-session-turns',
'3',
);
// Verify that execution completed successfully (not blocked by Stop hook)
// This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
const toolLogs = rig.readToolLogs();
const hasActivity = result.length > 0 || toolLogs.length > 0;
expect(hasActivity).toBe(true);
});
it('should handle multiple stop hooks all returning allow', async () => {
const allow1Script =
'echo \'{"decision": "allow", "reason": "First allows"}\'';
const allow2Script =
'echo \'{"decision": "allow", "reason": "Second allows"}\'';
const allow3Script =
'echo \'{"decision": "allow", "reason": "Third allows"}\'';
await rig.setup('stop-multi-all-allow', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: allow1Script,
name: 'stop-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allow2Script,
name: 'stop-allow-2',
timeout: 5000,
},
{
type: 'command',
command: 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 =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "First blocks"}\'';
const block2Script =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Second blocks"}\'';
await rig.setup('stop-multi-all-block', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: block1Script,
name: 'stop-block-1',
timeout: 5000,
},
{
type: 'command',
command: block2Script,
name: 'stop-block-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When Stop hooks block, agent continues execution normally (with max turns to prevent infinite loop)
const _result = await rig.run(
'Say all block',
'--max-session-turns',
'3',
);
// Verify Stop hook was invoked multiple times (indicating multiple rounds)
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
});
it('should handle stop hook with error alongside blocking hook', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked"}\'';
await rig.setup('stop-error-with-block', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/command',
name: 'stop-error-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'stop-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop)
const result = await rig.run(
'Say error with block',
'--max-session-turns',
'2',
);
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
});
// ==========================================================================
// 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 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "first"}}\'';
const hook2Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "second"}}\'';
await rig.setup('multi-sequential', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: hook1Script,
name: 'seq-hook-1',
timeout: 5000,
},
{
type: 'command',
command: 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 =
'echo \'{"decision": "block", "reason": "Blocked by first hook"}\'';
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('multi-first-blocks', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: blockScript,
name: 'seq-block-hook',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'seq-should-not-run',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When the first hook blocks, the UserPromptSubmit should be blocked
await expect(rig.run('Create a file')).rejects.toThrow(
/blocked|Blocked by first hook/i,
);
});
it('should pass output from first hook to second hook input', async () => {
const passScript1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "from first", "passthrough": "data"}}\'';
const passScript2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "received passthrough"}}\'';
await rig.setup('multi-passthrough', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: passScript1,
name: 'passthrough-hook-1',
timeout: 5000,
},
{
type: 'command',
command: 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 = 'echo \'{"decision": "allow"}\'';
const hook2Script = 'echo \'{"decision": "allow"}\'';
await rig.setup('multi-parallel', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: hook1Script,
name: 'parallel-hook-1',
timeout: 5000,
},
{
type: 'command',
command: 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 () => {
// For UserPromptSubmit hooks, command execution failure is treated as a blocking error
// So when one hook fails, the entire operation is blocked
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('multi-mixed', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'mixed-allow-hook',
timeout: 5000,
},
{
type: 'command',
command: '/nonexistent/command',
name: 'mixed-error-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// UserPromptSubmit hook command failure blocks the operation
await expect(rig.run('Say mixed')).rejects.toThrow(
/blocked|error|nonexistent/i,
);
});
it('should allow when any hook returns allow in parallel (OR logic)', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "blocked"}\'';
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('multi-or-logic', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: blockScript,
name: 'block-hook',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'allow-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// With security-sensitive OR logic, block should win (most restrictive decision wins)
await expect(rig.run('Say or logic')).rejects.toThrow(/blocked|error/i);
});
});
});
// ==========================================================================
// SessionStart Hooks
// Tests for session start lifecycle hooks with rich matcher and aggregator scenarios
// ==========================================================================
describe('SessionStart Hooks', () => {
describe('Single SessionStart Hook', () => {
it('should execute SessionStart hook on session startup', async () => {
const sessionStartScript =
'echo \'{decision: "allow", hookSpecificOutput: {additionalContext: "Session started successfully"}}\'';
await rig.setup('session-start-basic', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: sessionStartScript,
name: 'session-start-basic-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should inject additional context from SessionStart hook', async () => {
const contextScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Project context: TypeScript React app with strict linting rules"}}\'';
await rig.setup('session-start-context', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: contextScript,
name: 'session-start-context-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('What project context do you have?');
expect(result).toBeDefined();
expect(result.toLowerCase()).toContain('typescript');
});
it('should set environment variables via CLAUDE_ENV_FILE', async () => {
const envScript = `if [ -n "$CLAUDE_ENV_FILE" ]; then echo 'export TEST_VAR=session_start_value' >> "$CLAUDE_ENV_FILE"; echo 'export NODE_ENV=test' >> "$CLAUDE_ENV_FILE"; fi; echo '{"decision": "allow"}';`;
await rig.setup('session-start-env', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: envScript,
name: 'session-start-env-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Echo $TEST_VAR using Bash');
expect(result).toBeDefined();
});
it('should handle SessionStart hook with system message', async () => {
const systemMsgScript =
'echo \'{"decision": "allow", "systemMessage": "Welcome! Session initialized with custom settings"}\'';
await rig.setup('session-start-system-msg', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: systemMsgScript,
name: 'session-start-system-msg-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
});
describe('SessionStart Matcher Scenarios', () => {
it('should match startup source with matcher', async () => {
const startupScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Startup hook executed"}}\'';
const otherScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Other hook executed"}}\'';
await rig.setup('session-start-matcher-startup', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
hooks: [
{
type: 'command',
command: startupScript,
name: 'session-start-startup-hook',
timeout: 5000,
},
],
},
{
matcher: 'resume',
hooks: [
{
type: 'command',
command: otherScript,
name: 'session-start-resume-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say startup test');
expect(result).toBeDefined();
});
it('should match multiple sources with regex matcher', async () => {
const multiSourceScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Multi-source hook executed"}}\'';
await rig.setup('session-start-matcher-regex', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup|resume',
hooks: [
{
type: 'command',
command: multiSourceScript,
name: 'session-start-multi-source-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say regex matcher test');
expect(result).toBeDefined();
});
it('should match all sources with wildcard matcher', async () => {
const wildcardScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Wildcard hook executed"}}\'';
await rig.setup('session-start-matcher-wildcard', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: '*',
hooks: [
{
type: 'command',
command: wildcardScript,
name: 'session-start-wildcard-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say wildcard test');
expect(result).toBeDefined();
});
it('should not execute when matcher does not match', async () => {
const noMatchScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Should not execute"}}\'';
await rig.setup('session-start-matcher-no-match', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'clear', // This won't match startup
hooks: [
{
type: 'command',
command: noMatchScript,
name: 'session-start-clear-only-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say no match test');
expect(result).toBeDefined();
});
it('should match clear source with matcher', async () => {
const clearScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Clear hook executed"}}\'';
await rig.setup('session-start-matcher-clear', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'clear',
hooks: [
{
type: 'command',
command: clearScript,
name: 'session-start-clear-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say clear test');
expect(result).toBeDefined();
});
it('should match compact source with matcher', async () => {
const compactScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Compact hook executed"}}\'';
await rig.setup('session-start-matcher-compact', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'compact',
hooks: [
{
type: 'command',
command: compactScript,
name: 'session-start-compact-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say compact test');
expect(result).toBeDefined();
});
it('should match all four sources with regex matcher', async () => {
const allSourcesScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "All sources hook executed"}}\'';
await rig.setup('session-start-matcher-all-sources', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup|resume|clear|compact',
hooks: [
{
type: 'command',
command: allSourcesScript,
name: 'session-start-all-sources-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say all sources test');
expect(result).toBeDefined();
});
it('should match startup and resume but not clear or compact', async () => {
const startupResumeScript =
'echo \'{decision: "allow", hookSpecificOutput: {additionalContext: "Startup/Resume hook executed"}}\'';
const clearCompactScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Clear/Compact hook executed"}}\'';
await rig.setup('session-start-matcher-partial', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup|resume',
hooks: [
{
type: 'command',
command: startupResumeScript,
name: 'session-start-startup-resume-hook',
timeout: 5000,
},
],
},
{
matcher: 'clear|compact',
hooks: [
{
type: 'command',
command: clearCompactScript,
name: 'session-start-clear-compact-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say partial matcher test');
expect(result).toBeDefined();
});
it('should handle invalid regex in matcher gracefully', async () => {
const invalidRegexScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Fallback to exact match"}}\'';
await rig.setup('session-start-matcher-invalid-regex', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: '[invalid-regex', // Invalid regex pattern
hooks: [
{
type: 'command',
command: invalidRegexScript,
name: 'session-start-invalid-regex-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say invalid regex test');
expect(result).toBeDefined();
});
it('should match all session start sources with individual hooks', async () => {
const startupScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Startup triggered"}}\'';
const resumeScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Resume triggered"}}\'';
const clearScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Clear triggered"}}\'';
const compactScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Compact triggered"}}\'';
await rig.setup('session-start-all-sources-individual', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
hooks: [
{
type: 'command',
command: startupScript,
name: 'session-start-startup-hook',
timeout: 5000,
},
],
},
{
matcher: 'resume',
hooks: [
{
type: 'command',
command: resumeScript,
name: 'session-start-resume-hook',
timeout: 5000,
},
],
},
{
matcher: 'clear',
hooks: [
{
type: 'command',
command: clearScript,
name: 'session-start-clear-hook',
timeout: 5000,
},
],
},
{
matcher: 'compact',
hooks: [
{
type: 'command',
command: compactScript,
name: 'session-start-compact-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say all sources individual test');
expect(result).toBeDefined();
});
});
describe('Multiple SessionStart Hooks', () => {
it('should execute multiple parallel SessionStart hooks', async () => {
const script1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Parallel hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Parallel hook 2"}}\'';
const script3 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Parallel hook 3"}}\'';
await rig.setup('session-start-multi-parallel', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: script1,
name: 'session-start-parallel-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'session-start-parallel-2',
timeout: 5000,
},
{
type: 'command',
command: script3,
name: 'session-start-parallel-3',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say multi parallel');
expect(result).toBeDefined();
});
it('should execute sequential SessionStart hooks in order', async () => {
const script1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Sequential hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Sequential hook 2"}}\'';
await rig.setup('session-start-multi-sequential', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
sequential: true,
hooks: [
{
type: 'command',
command: script1,
name: 'session-start-seq-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'session-start-seq-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say sequential');
expect(result).toBeDefined();
});
it('should concatenate additional context from multiple hooks', async () => {
const context1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Context from hook 1"}}\'';
const context2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Context from hook 2"}}\'';
await rig.setup('session-start-multi-context', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: context1,
name: 'session-start-ctx-1',
timeout: 5000,
},
{
type: 'command',
command: context2,
name: 'session-start-ctx-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('What context do you have?');
expect(result).toBeDefined();
});
it('should handle system messages from multiple hooks', async () => {
const msg1 =
'echo \'{"decision": "allow", "systemMessage": "System message 1"}\'';
const msg2 =
'echo \'{"decision": "allow", "systemMessage": "System message 2"}\'';
await rig.setup('session-start-multi-system-msg', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: msg1,
name: 'session-start-sys-1',
timeout: 5000,
},
{
type: 'command',
command: msg2,
name: 'session-start-sys-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
});
describe('SessionStart Error Handling', () => {
it('should continue session when hook exits with non-blocking error', async () => {
await rig.setup('session-start-nonblocking-error', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: 'echo warning && exit 1',
name: 'session-start-error-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say error test');
expect(result).toBeDefined();
});
it('should continue session when hook command does not exist', async () => {
await rig.setup('session-start-missing-command', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/session/start/command',
name: 'session-start-missing-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say missing test');
expect(result).toBeDefined();
});
it('should handle hook timeout gracefully', async () => {
await rig.setup('session-start-timeout', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: 'sleep 60',
name: 'session-start-timeout-hook',
timeout: 1000, // 1 second timeout
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say timeout test');
expect(result).toBeDefined();
});
});
});
// ==========================================================================
// SessionEnd Hooks
// Tests for session end lifecycle hooks with various exit reasons
// ==========================================================================
describe('SessionEnd Hooks', () => {
describe('Single SessionEnd Hook', () => {
it('should execute SessionEnd hook on session end', async () => {
const sessionEndScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('session-end-basic', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: sessionEndScript,
name: 'session-end-basic-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
it('should execute SessionEnd hook with cleanup tasks', async () => {
const cleanupScript =
'echo {decision: "allow", hookSpecificOutput: {additionalContext: "Cleanup completed"}}';
await rig.setup('session-end-cleanup', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: cleanupScript,
name: 'session-end-cleanup-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say cleanup test');
expect(result).toBeDefined();
});
});
describe('SessionEnd Matcher Scenarios', () => {
it('should match specific exit reason with matcher', async () => {
const clearScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Clear hook executed"}}\'';
const logoutScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Logout hook executed"}}\'';
await rig.setup('session-end-matcher-clear', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
matcher: 'clear',
hooks: [
{
type: 'command',
command: clearScript,
name: 'session-end-clear-hook',
timeout: 5000,
},
],
},
{
matcher: 'logout',
hooks: [
{
type: 'command',
command: logoutScript,
name: 'session-end-logout-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say matcher test');
expect(result).toBeDefined();
});
it('should match multiple exit reasons with regex matcher', async () => {
const multiReasonScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Multi-reason hook executed"}}\'';
await rig.setup('session-end-matcher-regex', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
matcher: 'clear|logout|other',
hooks: [
{
type: 'command',
command: multiReasonScript,
name: 'session-end-multi-reason-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say regex matcher test');
expect(result).toBeDefined();
});
it('should match all reasons with wildcard matcher', async () => {
const wildcardScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Wildcard end hook executed"}}\'';
await rig.setup('session-end-matcher-wildcard', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
matcher: '*',
hooks: [
{
type: 'command',
command: wildcardScript,
name: 'session-end-wildcard-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say wildcard test');
expect(result).toBeDefined();
});
it('should handle invalid regex in SessionEnd matcher gracefully', async () => {
const invalidRegexScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "SessionEnd fallback to exact match"}}\'';
await rig.setup('session-end-matcher-invalid-regex', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
matcher: '[invalid-regex', // Invalid regex pattern
hooks: [
{
type: 'command',
command: invalidRegexScript,
name: 'session-end-invalid-regex-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say invalid regex SessionEnd test');
expect(result).toBeDefined();
});
it('should match all SessionEnd reasons with individual hooks', async () => {
const clearScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Clear reason triggered"}}\'';
const logoutScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Logout reason triggered"}}\'';
const promptExitScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "PromptInputExit reason triggered"}}\'';
const bypassDisabledScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Bypass permissions disabled triggered"}}\'';
const otherScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Other reason triggered"}}\'';
await rig.setup('session-end-all-reasons-individual', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
matcher: 'clear',
hooks: [
{
type: 'command',
command: clearScript,
name: 'session-end-clear-hook',
timeout: 5000,
},
],
},
{
matcher: 'logout',
hooks: [
{
type: 'command',
command: logoutScript,
name: 'session-end-logout-hook',
timeout: 5000,
},
],
},
{
matcher: 'promptInputExit',
hooks: [
{
type: 'command',
command: promptExitScript,
name: 'session-end-prompt-exit-hook',
timeout: 5000,
},
],
},
{
matcher: 'bypass_permissions_disabled',
hooks: [
{
type: 'command',
command: bypassDisabledScript,
name: 'session-end-bypass-disabled-hook',
timeout: 5000,
},
],
},
{
matcher: 'other',
hooks: [
{
type: 'command',
command: otherScript,
name: 'session-end-other-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say all SessionEnd reasons test');
expect(result).toBeDefined();
});
});
describe('Multiple SessionEnd Hooks', () => {
it('should execute multiple parallel SessionEnd hooks', async () => {
const script1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "End hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "End hook 2"}}\'';
await rig.setup('session-end-multi-parallel', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: script1,
name: 'session-end-parallel-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'session-end-parallel-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say multi parallel end');
expect(result).toBeDefined();
});
it('should execute sequential SessionEnd hooks in order', async () => {
const script1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Sequential end hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Sequential end hook 2"}}\'';
await rig.setup('session-end-multi-sequential', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
sequential: true,
hooks: [
{
type: 'command',
command: script1,
name: 'session-end-seq-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'session-end-seq-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say sequential end');
expect(result).toBeDefined();
});
it('should concatenate additional context from multiple hooks', async () => {
const context1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "End context from hook 1"}}\'';
const context2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "End context from hook 2"}}\'';
await rig.setup('session-end-multi-context', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: context1,
name: 'session-end-ctx-1',
timeout: 5000,
},
{
type: 'command',
command: context2,
name: 'session-end-ctx-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say end context test');
expect(result).toBeDefined();
});
});
describe('SessionEnd Block Scenarios', () => {
it('should block session end when hook returns block decision', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Session end blocked by policy"}\'';
await rig.setup('session-end-block', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: blockScript,
name: 'session-end-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say block test');
expect(result).toBeDefined();
// Session should not end, agent continues
expect(result.toLowerCase()).toContain('block');
});
it('should allow session end when hook returns allow decision', async () => {
const allowScript =
'echo \'{"decision": "allow", "reason": "Session end allowed"}\'';
await rig.setup('session-end-allow', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'session-end-allow-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say allow test');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should block when one of multiple parallel hooks returns block', async () => {
const allowScript =
'echo \'{"decision": "allow", "reason": "Allowed"}\'';
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked by security policy"}\'';
await rig.setup('session-end-multi-one-blocks', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'session-end-allow-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'session-end-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say multi block test');
expect(result).toBeDefined();
expect(result.toLowerCase()).toContain('block');
});
it('should block when first sequential hook returns block', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "First hook blocks session end"}\'';
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('session-end-seq-first-blocks', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
sequential: true,
hooks: [
{
type: 'command',
command: blockScript,
name: 'session-end-seq-block-hook',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'session-end-seq-allow-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say seq block test');
expect(result).toBeDefined();
expect(result.toLowerCase()).toContain('block');
});
it('should allow when all hooks return allow', async () => {
const allow1Script =
'echo \'{"decision": "allow", "reason": "First allows"}\'';
const allow2Script =
'echo \'{"decision": "allow", "reason": "Second allows"}\'';
await rig.setup('session-end-all-allow', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: allow1Script,
name: 'session-end-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allow2Script,
name: 'session-end-allow-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say all allow test');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should handle block with reason in session end', async () => {
const blockWithReasonScript =
'echo \'{"decision": "block", "reason": "Critical operations pending - cannot end session"} \'';
await rig.setup('session-end-block-with-reason', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: blockWithReasonScript,
name: 'session-end-block-reason-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say block with reason');
expect(result).toBeDefined();
expect(result.toLowerCase()).toContain('block');
});
});
describe('SessionEnd Error Handling', () => {
it('should continue session end when hook exits with non-blocking error', async () => {
await rig.setup('session-end-nonblocking-error', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: 'echo warning && exit 1',
name: 'session-end-error-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say error test');
expect(result).toBeDefined();
});
it('should continue session end when hook command does not exist', async () => {
await rig.setup('session-end-missing-command', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/session/end/command',
name: 'session-end-missing-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say missing test');
expect(result).toBeDefined();
});
});
describe('Multiple SessionEnd Hooks', () => {
it('should block when one of multiple parallel hooks returns block', async () => {
const allowScript = 'echo \'{"decision": "allow"}\'';
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked"}\'';
await rig.setup('session-end-multi-one-blocks', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'session-end-allow-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'session-end-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
// SessionEnd hooks run after the main command completes and don't affect the main output
expect(result.toLowerCase()).not.toContain('block');
});
it('should block when first sequential hook returns block', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked"}\'';
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('session-end-seq-first-blocks', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
sequential: true,
hooks: [
{
type: 'command',
command: blockScript,
name: 'session-end-seq-block-hook',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'session-end-seq-allow-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say test');
expect(result).toBeDefined();
// SessionEnd hooks run after the main command completes and don't affect the main output
expect(result.toLowerCase()).not.toContain('block');
});
it('should handle multiple hooks all returning allow', async () => {
const allow1Script =
'echo \'{"decision": "allow", "reason": "First allows"}\'';
const allow2Script =
'echo \'{"decision": "allow", "reason": "Second allows"}\'';
await rig.setup('session-end-multi-all-allow', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: allow1Script,
name: 'session-end-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allow2Script,
name: 'session-end-allow-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should concatenate additional context from multiple hooks', async () => {
const context1Script =
'echo {decision: "allow", hookSpecificOutput: {additionalContext: "context from session end hook 1"}}';
const context2Script =
'echo {decision: "allow", hookSpecificOutput: {additionalContext: "context from session end hook 2"}}';
await rig.setup('session-end-multi-context', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: context1Script,
name: 'session-end-context-1',
timeout: 5000,
},
{
type: 'command',
command: context2Script,
name: 'session-end-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 =
'echo \'{"decision": "block", "reason": "Blocked"}\'';
await rig.setup('session-end-error-with-block', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/command',
name: 'session-end-error-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'session-end-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say test');
expect(result).toBeDefined();
// SessionEnd hooks run after the main command completes and don't affect the main output
expect(result.toLowerCase()).not.toContain('block');
});
it('should handle hook timeout alongside blocking hook', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked"}\'';
await rig.setup('session-end-timeout-with-block', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: 'sleep 60',
name: 'session-end-timeout-hook',
timeout: 1000,
},
{
type: 'command',
command: blockScript,
name: 'session-end-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say test');
expect(result).toBeDefined();
// SessionEnd hooks run after the main command completes and don't affect the main output
expect(result.toLowerCase()).not.toContain('block');
});
it('should handle system messages from multiple hooks', async () => {
const msg1Script =
'echo \'{"decision": "allow", "systemMessage": "System message 1 from SessionEnd"}\'';
const msg2Script =
'echo \'{"decision": "allow", "systemMessage": "System message 2 from SessionEnd"}\'';
await rig.setup('session-end-multi-system-msg', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: msg1Script,
name: 'session-end-msg-1',
timeout: 5000,
},
{
type: 'command',
command: msg2Script,
name: 'session-end-msg-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
});
});
// ==========================================================================
// Combined Hooks
// Tests for using multiple hook types together
// ==========================================================================
// Combined Hooks
// Tests for using multiple hook types together
// ==========================================================================
describe('Combined Hooks', () => {
it('should execute both Stop and UserPromptSubmit hooks in same session', async () => {
const stopScript = 'echo \'{"decision": "allow"}\'';
const upsScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('combined-both-hooks', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: stopScript,
name: 'stop-hook',
timeout: 5000,
},
],
},
],
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: upsScript,
name: 'ups-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say both hooks');
expect(result).toBeDefined();
});
it('should execute multiple hook types together', async () => {
const upsScript = 'echo \'{"decision": "allow"}\'';
const sessionEndScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('combined-ups-sessionend', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: upsScript,
name: 'ups-hook',
timeout: 5000,
},
],
},
],
SessionEnd: [
{
hooks: [
{
type: 'command',
command: sessionEndScript,
name: 'session-end-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello with multiple hooks');
expect(result).toBeDefined();
});
it('should execute Stop, UserPromptSubmit and SessionEnd hooks together', async () => {
const stopScript = 'echo \'{"decision": "allow"}\'';
const upsScript = 'echo \'{"decision": "allow"}\'';
const sessionEndScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('combined-three-hooks', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: stopScript,
name: 'stop-hook',
timeout: 5000,
},
],
},
],
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: upsScript,
name: 'ups-hook',
timeout: 5000,
},
],
},
],
SessionEnd: [
{
hooks: [
{
type: 'command',
command: sessionEndScript,
name: 'session-end-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello with three hooks');
expect(result).toBeDefined();
});
it('should execute all hook types together', async () => {
const stopScript = 'echo \'{"decision": "allow"}\'';
const upsScript = 'echo \'{"decision": "allow"}\'';
const sessionEndScript = 'echo \'{"decision": "allow"}\'';
const permissionScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('combined-all-hooks', {
settings: {
hooksConfig: { enabled: true },
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: stopScript,
name: 'stop-hook',
timeout: 5000,
},
],
},
],
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: upsScript,
name: 'ups-hook',
timeout: 5000,
},
],
},
],
SessionEnd: [
{
hooks: [
{
type: 'command',
command: sessionEndScript,
name: 'session-end-hook',
timeout: 5000,
},
],
},
],
PermissionRequest: [
{
hooks: [
{
type: 'command',
command: permissionScript,
name: 'permission-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello with all 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 () => {
const scriptFileHook =
'echo \'{"decision": "allow", "reason": "Approved by script file", "hookSpecificOutput": {"additionalContext": "Script file executed successfully"}}\'';
await rig.setup('script-file-hook', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: scriptFileHook,
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 () => {
const scriptBlockHook =
'echo \'{"decision": "block", "reason": "Blocked by security script"}\'';
await rig.setup('script-file-block-hook', {
settings: {
hooksConfig: { enabled: true },
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: scriptBlockHook,
name: 'script-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When UserPromptSubmit hook blocks, CLI exits with non-zero code
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
});
// ==========================================================================
// PermissionRequest Hooks
// Tests for permission request lifecycle hooks that control tool access
// ==========================================================================
describe('PermissionRequest Hooks', () => {
describe('Single PermissionRequest Hook - Allow Scenarios', () => {
it('should allow tool execution when hook returns allow decision', async () => {
const allowScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Tool access granted by permission hook"}}\'';
await rig.setup('permission-req-allow-basic', {
settings: {
hooksConfig: { enabled: true },
hooks: {
PermissionRequest: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'permission-req-allow-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run(
'Create a file test.txt with content "hello"',
);
expect(result).toBeDefined();
const fileContent = rig.readFile('test.txt');
expect(fileContent).toContain('hello');
});
it('should allow specific tools based on tool name matching', async () => {
const allowSafeToolsScript = `
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
if [ "$TOOL_NAME" = "Read" ] || [ "$TOOL_NAME" = "Grep" ]; then
echo '{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Safe tool access granted"}}'
else
echo '{}'
fi
`;
await rig.setup('permission-req-allow-safe-tools', {
settings: {
hooksConfig: { enabled: true },
hooks: {
PermissionRequest: [
{
matcher: 'Read|Grep',
hooks: [
{
type: 'command',
command: allowSafeToolsScript,
name: 'permission-req-allow-safe-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// Test with a Read operation
const result = await rig.run('Read the package.json file');
expect(result).toBeDefined();
});
});
describe('Single PermissionRequest Hook - Deny Scenarios', () => {
it('should deny tool execution when hook returns deny decision', async () => {
const denyScript =
'echo \'{"decision": "deny", "reason": "Tool execution denied by security hook", "hookSpecificOutput": {"additionalContext": "Security policy violation"}}\'';
await rig.setup('permission-req-deny-basic', {
settings: {
hooksConfig: { enabled: true },
hooks: {
PermissionRequest: [
{
hooks: [
{
type: 'command',
command: denyScript,
name: 'permission-req-deny-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// Note: Currently the PermissionRequest deny decision may not block tool execution
// This test verifies that the hook is executed and returns the expected decision
const result = await rig.run(
'Create a file denied.txt with content "should be blocked"',
);
expect(result).toBeDefined();
// The hook is triggered but current implementation may not block execution
// This highlights the gap where deny decisions don't prevent tool execution
// In future, we'd expect the deny decision to block execution and result to contain deny-related message
});
it('should block dangerous operations based on tool input matching', async () => {
const blockDangerousOpsScript = `
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [ "$TOOL_NAME" = "Bash" ] && [[ "$COMMAND" == *"rm -rf"* ]]; then
echo '{"decision": "deny", "reason": "Dangerous command blocked", "hookSpecificOutput": {"additionalContext": "Security threat detected"}}'
else
echo '{"decision": "allow"}'
fi
`;
await rig.setup('permission-req-block-dangerous', {
settings: {
hooksConfig: { enabled: true },
hooks: {
PermissionRequest: [
{
matcher: 'Bash',
hooks: [
{
type: 'command',
command: blockDangerousOpsScript,
name: 'permission-req-block-dangerous-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// This command should ideally be blocked by the hook
// Note: Currently the PermissionRequest deny decision may not block tool execution
const result = await rig.run('Execute bash command: rm -rf /tmp');
expect(result).toBeDefined();
// The hook system correctly identifies dangerous operations
// But current implementation may not fully enforce the deny decision
});
});
describe('Multiple PermissionRequest Hooks - Allow Scenarios', () => {
it('should allow tool execution when all hooks return allow decision', async () => {
const allowScript1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "First permission check passed"}}\'';
const allowScript2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Second permission check passed"}}\'';
await rig.setup('permission-req-multi-allow', {
settings: {
hooksConfig: { enabled: true },
hooks: {
PermissionRequest: [
{
hooks: [
{
type: 'command',
command: allowScript1,
name: 'permission-req-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allowScript2,
name: 'permission-req-allow-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run(
'Create a file multi-test.txt with content "multi allow"',
);
expect(result).toBeDefined();
const fileContent = rig.readFile('multi-test.txt');
expect(fileContent).toContain('multi allow');
});
it('should allow execution with sequential permission checks', async () => {
const allowScript1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "First sequential check passed"}}\'';
const allowScript2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Second sequential check passed"}}\'';
await rig.setup('permission-req-sequential-allow', {
settings: {
hooksConfig: { enabled: true },
hooks: {
PermissionRequest: [
{
sequential: true,
hooks: [
{
type: 'command',
command: allowScript1,
name: 'permission-req-seq-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allowScript2,
name: 'permission-req-seq-allow-2',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Read this test file');
expect(result).toBeDefined();
});
});
describe('Multiple PermissionRequest Hooks - Deny Scenarios', () => {
it('should deny tool execution when one hook returns deny decision in parallel', async () => {
const allowScript = 'echo \'{"decision": "allow"}\'';
const denyScript =
'echo \'{"decision": "deny", "reason": "Denied by security policy"}\'';
await rig.setup('permission-req-multi-one-denies', {
settings: {
hooksConfig: { enabled: true },
hooks: {
PermissionRequest: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'permission-req-allow-parallel',
timeout: 5000,
},
{
type: 'command',
command: denyScript,
name: 'permission-req-deny-parallel',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// Note: Currently the PermissionRequest deny decision may not block tool execution
// In a proper implementation, one deny decision among parallel hooks should block execution
const result = await rig.run(
'Create a file blocked.txt with content "should not be created"',
);
expect(result).toBeDefined();
// This test demonstrates the current behavior where deny decisions may not block execution
// Future implementation should ensure that a deny decision blocks the tool execution
});
it('should deny execution when first sequential hook denies', async () => {
const denyScript =
'echo \'{"decision": "deny", "reason": "First check denied execution"}\'';
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('permission-req-sequential-first-denies', {
settings: {
hooksConfig: { enabled: true },
hooks: {
PermissionRequest: [
{
sequential: true,
hooks: [
{
type: 'command',
command: denyScript,
name: 'permission-req-seq-deny-first',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'permission-req-seq-allow-second',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// Note: Currently the PermissionRequest deny decision may not block tool execution
// In a proper implementation, the first deny decision should prevent subsequent hooks from executing
// and block the tool execution entirely
const result = await rig.run(
'Try to write a file that should be blocked',
);
expect(result).toBeDefined();
// This test highlights where the implementation could be strengthened
// to properly respect deny decisions in sequential hook execution
});
});
describe('PermissionRequest Matcher Scenarios', () => {
it('should match specific tools with regex matcher', async () => {
const specificToolScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Specific tool matched and allowed"}}\'';
await rig.setup('permission-req-matcher-specific', {
settings: {
hooksConfig: { enabled: true },
hooks: {
PermissionRequest: [
{
matcher: 'Read|Write',
hooks: [
{
type: 'command',
command: specificToolScript,
name: 'permission-req-specific-tool-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Read the current directory');
expect(result).toBeDefined();
});
it('should match all tools with wildcard matcher', async () => {
const wildcardScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Wildcard matcher allowed all tools"}}\'';
await rig.setup('permission-req-matcher-wildcard', {
settings: {
hooksConfig: { enabled: true },
hooks: {
PermissionRequest: [
{
matcher: '*',
hooks: [
{
type: 'command',
command: wildcardScript,
name: 'permission-req-wildcard-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say wildcard test');
expect(result).toBeDefined();
});
});
});
});