Files
gemini-cli/packages/core/src/hooks/hookRunner.test.ts

899 lines
26 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { HookRunner } from './hookRunner.js';
import { HookEventName, HookType, ConfigSource } from './types.js';
import type { HookConfig } from './types.js';
import type { HookInput } from './types.js';
import type { Readable, Writable } from 'node:stream';
import type { Config } from '../config/config.js';
// Mock type for the child_process spawn
type MockChildProcessWithoutNullStreams = ChildProcessWithoutNullStreams & {
mockStdoutOn: ReturnType<typeof vi.fn>;
mockStderrOn: ReturnType<typeof vi.fn>;
mockProcessOn: ReturnType<typeof vi.fn>;
};
// Mock child_process with importOriginal for partial mocking
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as object),
spawn: vi.fn(),
};
});
// Mock debugLogger using vi.hoisted
const mockDebugLogger = vi.hoisted(() => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}));
vi.mock('../utils/debugLogger.js', () => ({
debugLogger: mockDebugLogger,
}));
// Mock console methods
const mockConsole = {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
vi.stubGlobal('console', mockConsole);
describe('HookRunner', () => {
let hookRunner: HookRunner;
let mockSpawn: MockChildProcessWithoutNullStreams;
let mockConfig: Config;
const mockInput: HookInput = {
session_id: 'test-session',
transcript_path: '/path/to/transcript',
cwd: '/test/project',
hook_event_name: 'BeforeTool',
timestamp: '2025-01-01T00:00:00.000Z',
};
beforeEach(() => {
vi.resetAllMocks();
mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(true),
sanitizationConfig: {
enableEnvironmentVariableRedaction: true,
},
} as unknown as Config;
hookRunner = new HookRunner(mockConfig);
// Mock spawn with accessible mock functions
const mockStdoutOn = vi.fn();
const mockStderrOn = vi.fn();
const mockProcessOn = vi.fn();
mockSpawn = {
stdin: {
write: vi.fn(),
end: vi.fn(),
on: vi.fn(),
} as unknown as Writable,
stdout: {
on: mockStdoutOn,
} as unknown as Readable,
stderr: {
on: mockStderrOn,
} as unknown as Readable,
on: mockProcessOn,
kill: vi.fn(),
killed: false,
mockStdoutOn,
mockStderrOn,
mockProcessOn,
} as unknown as MockChildProcessWithoutNullStreams;
vi.mocked(spawn).mockReturnValue(mockSpawn);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('executeHook', () => {
describe('security checks', () => {
it('should block project hooks in untrusted folders', async () => {
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
const projectHookConfig: HookConfig = {
type: HookType.Command,
command: './hooks/test.sh',
source: ConfigSource.Project,
};
const result = await hookRunner.executeHook(
projectHookConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(false);
expect(result.error?.message).toContain(
'Security: Blocked execution of project hook in untrusted folder',
);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Security: Blocked execution'),
);
expect(spawn).not.toHaveBeenCalled();
});
it('should allow project hooks in trusted folders', async () => {
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(true);
const projectHookConfig: HookConfig = {
type: HookType.Command,
command: './hooks/test.sh',
source: ConfigSource.Project,
};
// Mock successful execution
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setTimeout(() => callback(0), 10);
}
},
);
const result = await hookRunner.executeHook(
projectHookConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(true);
expect(spawn).toHaveBeenCalled();
});
it('should allow non-project hooks even in untrusted folders', async () => {
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
const systemHookConfig: HookConfig = {
type: HookType.Command,
command: './hooks/test.sh',
source: ConfigSource.System,
};
// Mock successful execution
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setTimeout(() => callback(0), 10);
}
},
);
const result = await hookRunner.executeHook(
systemHookConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(true);
expect(spawn).toHaveBeenCalled();
});
});
describe('command hooks', () => {
const commandConfig: HookConfig = {
type: HookType.Command,
command: './hooks/test.sh',
timeout: 5000,
};
it('should execute command hook successfully', async () => {
const mockOutput = { decision: 'allow', reason: 'All good' };
// Mock successful execution
mockSpawn.mockStdoutOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data') {
setImmediate(() =>
callback(Buffer.from(JSON.stringify(mockOutput))),
);
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(0));
}
},
);
const result = await hookRunner.executeHook(
commandConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(true);
expect(result.output).toEqual(mockOutput);
expect(result.exitCode).toBe(0);
expect(mockSpawn.stdin.write).toHaveBeenCalledWith(
JSON.stringify(mockInput),
);
});
it('should handle command hook failure', async () => {
const errorMessage = 'Command failed';
mockSpawn.mockStderrOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data') {
setImmediate(() => callback(Buffer.from(errorMessage)));
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(1));
}
},
);
const result = await hookRunner.executeHook(
commandConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(false);
expect(result.exitCode).toBe(1);
expect(result.stderr).toBe(errorMessage);
});
it('should use hook name in error messages if available', async () => {
const namedConfig: HookConfig = {
name: 'my-friendly-hook',
type: HookType.Command,
command: './hooks/fail.sh',
};
// Mock error during spawn
vi.mocked(spawn).mockImplementationOnce(() => {
throw new Error('Spawn error');
});
await hookRunner.executeHook(
namedConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'(hook: my-friendly-hook): Error: Spawn error',
),
);
});
it('should handle command hook timeout', async () => {
const shortTimeoutConfig: HookConfig = {
type: HookType.Command,
command: './hooks/slow.sh',
timeout: 50, // Very short timeout for testing
};
let closeCallback: ((code: number) => void) | undefined;
let killWasCalled = false;
// Mock a hanging process that registers the close handler but doesn't call it initially
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
closeCallback = callback; // Store the callback but don't call it yet
}
},
);
// Mock the kill method to simulate the process being killed
mockSpawn.kill = vi.fn().mockImplementation((_signal: string) => {
killWasCalled = true;
// Simulate that killing the process triggers the close event
if (closeCallback) {
setImmediate(() => {
closeCallback!(128); // Exit code 128 indicates process was killed by signal
});
}
return true;
});
const result = await hookRunner.executeHook(
shortTimeoutConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(false);
expect(killWasCalled).toBe(true);
expect(result.error?.message).toContain('timed out');
expect(mockSpawn.kill).toHaveBeenCalledWith('SIGTERM');
});
it('should expand environment variables in commands', async () => {
const configWithEnvVar: HookConfig = {
type: HookType.Command,
command: '$GEMINI_PROJECT_DIR/hooks/test.sh',
};
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(0));
}
},
);
await hookRunner.executeHook(
configWithEnvVar,
HookEventName.BeforeTool,
mockInput,
);
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/bash|powershell/),
expect.arrayContaining([
expect.stringMatching(/['"]?\/test\/project['"]?\/hooks\/test\.sh/),
]),
expect.objectContaining({
shell: false,
env: expect.objectContaining({
GEMINI_PROJECT_DIR: '/test/project',
CLAUDE_PROJECT_DIR: '/test/project',
}),
}),
);
});
it('should not allow command injection via GEMINI_PROJECT_DIR', async () => {
const maliciousCwd = '/test/project; echo "pwned" > /tmp/pwned';
const mockMaliciousInput: HookInput = {
...mockInput,
cwd: maliciousCwd,
};
const config: HookConfig = {
type: HookType.Command,
command: 'ls $GEMINI_PROJECT_DIR',
};
// Mock the process closing immediately
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(0));
}
},
);
await hookRunner.executeHook(
config,
HookEventName.BeforeTool,
mockMaliciousInput,
);
// If secure, spawn will be called with the shell executable and escaped command
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/bash|powershell/),
expect.arrayContaining([
expect.stringMatching(/ls (['"]).*echo.*pwned.*\1/),
]),
expect.objectContaining({ shell: false }),
);
});
});
});
describe('executeHooksParallel', () => {
it('should execute multiple hooks in parallel', async () => {
const configs: HookConfig[] = [
{ type: HookType.Command, command: './hook1.sh' },
{ type: HookType.Command, command: './hook2.sh' },
];
// Mock both commands to succeed
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(0));
}
},
);
const results = await hookRunner.executeHooksParallel(
configs,
HookEventName.BeforeTool,
mockInput,
);
expect(results).toHaveLength(2);
expect(results.every((r) => r.success)).toBe(true);
expect(spawn).toHaveBeenCalledTimes(2);
});
it('should handle mixed success and failure', async () => {
const configs: HookConfig[] = [
{ type: HookType.Command, command: './hook1.sh' },
{ type: HookType.Command, command: './hook2.sh' },
];
let callCount = 0;
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
const exitCode = callCount++ === 0 ? 0 : 1; // First succeeds, second fails
setImmediate(() => callback(exitCode));
}
},
);
const results = await hookRunner.executeHooksParallel(
configs,
HookEventName.BeforeTool,
mockInput,
);
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
expect(results[1].success).toBe(false);
});
});
describe('executeHooksSequential', () => {
it('should execute multiple hooks in sequence', async () => {
const configs: HookConfig[] = [
{ type: HookType.Command, command: './hook1.sh' },
{ type: HookType.Command, command: './hook2.sh' },
];
const executionOrder: string[] = [];
// Mock both commands to succeed
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
const args = vi.mocked(spawn).mock.calls[
executionOrder.length
][1] as string[];
const command = args[args.length - 1];
executionOrder.push(command);
setImmediate(() => callback(0));
}
},
);
const results = await hookRunner.executeHooksSequential(
configs,
HookEventName.BeforeTool,
mockInput,
);
expect(results).toHaveLength(2);
expect(results.every((r) => r.success)).toBe(true);
expect(spawn).toHaveBeenCalledTimes(2);
// Verify they were called sequentially
expect(executionOrder).toEqual(['./hook1.sh', './hook2.sh']);
});
it('should continue execution even if a hook fails', async () => {
const configs: HookConfig[] = [
{ type: HookType.Command, command: './hook1.sh' },
{ type: HookType.Command, command: './hook2.sh' },
{ type: HookType.Command, command: './hook3.sh' },
];
let callCount = 0;
mockSpawn.mockStderrOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data' && callCount === 1) {
// Second hook fails
setImmediate(() => callback(Buffer.from('Hook 2 failed')));
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
const exitCode = callCount++ === 1 ? 1 : 0; // Second fails, others succeed
setImmediate(() => callback(exitCode));
}
},
);
const results = await hookRunner.executeHooksSequential(
configs,
HookEventName.BeforeTool,
mockInput,
);
expect(results).toHaveLength(3);
expect(results[0].success).toBe(true);
expect(results[1].success).toBe(false);
expect(results[2].success).toBe(true);
expect(spawn).toHaveBeenCalledTimes(3);
});
it('should pass modified input from one hook to the next for BeforeAgent', async () => {
const configs: HookConfig[] = [
{ type: HookType.Command, command: './hook1.sh' },
{ type: HookType.Command, command: './hook2.sh' },
];
const mockBeforeAgentInput = {
...mockInput,
prompt: 'Original prompt',
};
const mockOutput1 = {
decision: 'allow' as const,
hookSpecificOutput: {
additionalContext: 'Context from hook 1',
},
};
let hookCallCount = 0;
mockSpawn.mockStdoutOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data') {
if (hookCallCount === 0) {
setImmediate(() =>
callback(Buffer.from(JSON.stringify(mockOutput1))),
);
}
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
hookCallCount++;
setImmediate(() => callback(0));
}
},
);
const results = await hookRunner.executeHooksSequential(
configs,
HookEventName.BeforeAgent,
mockBeforeAgentInput,
);
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
expect(results[0].output).toEqual(mockOutput1);
// Verify that the second hook received modified input
const secondHookInput = JSON.parse(
vi.mocked(mockSpawn.stdin.write).mock.calls[1][0],
);
expect(secondHookInput.prompt).toContain('Original prompt');
expect(secondHookInput.prompt).toContain('Context from hook 1');
});
it('should pass modified LLM request from one hook to the next for BeforeModel', async () => {
const configs: HookConfig[] = [
{ type: HookType.Command, command: './hook1.sh' },
{ type: HookType.Command, command: './hook2.sh' },
];
const mockBeforeModelInput = {
...mockInput,
llm_request: {
model: 'gemini-1.5-pro',
messages: [{ role: 'user', content: 'Hello' }],
},
};
const mockOutput1 = {
decision: 'allow' as const,
hookSpecificOutput: {
llm_request: {
temperature: 0.7,
},
},
};
let hookCallCount = 0;
mockSpawn.mockStdoutOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data') {
if (hookCallCount === 0) {
setImmediate(() =>
callback(Buffer.from(JSON.stringify(mockOutput1))),
);
}
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
hookCallCount++;
setImmediate(() => callback(0));
}
},
);
const results = await hookRunner.executeHooksSequential(
configs,
HookEventName.BeforeModel,
mockBeforeModelInput,
);
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
// Verify that the second hook received modified input
const secondHookInput = JSON.parse(
vi.mocked(mockSpawn.stdin.write).mock.calls[1][0],
);
expect(secondHookInput.llm_request.model).toBe('gemini-1.5-pro');
expect(secondHookInput.llm_request.temperature).toBe(0.7);
});
it('should not modify input if hook fails', async () => {
const configs: HookConfig[] = [
{ type: HookType.Command, command: './hook1.sh' },
{ type: HookType.Command, command: './hook2.sh' },
];
mockSpawn.mockStderrOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data') {
setImmediate(() => callback(Buffer.from('Hook failed')));
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(1)); // All hooks fail
}
},
);
const results = await hookRunner.executeHooksSequential(
configs,
HookEventName.BeforeTool,
mockInput,
);
expect(results).toHaveLength(2);
expect(results.every((r) => !r.success)).toBe(true);
// Verify that both hooks received the same original input
const firstHookInput = JSON.parse(
vi.mocked(mockSpawn.stdin.write).mock.calls[0][0],
);
const secondHookInput = JSON.parse(
vi.mocked(mockSpawn.stdin.write).mock.calls[1][0],
);
expect(firstHookInput).toEqual(secondHookInput);
});
});
describe('invalid JSON handling', () => {
const commandConfig: HookConfig = {
type: HookType.Command,
command: './hooks/test.sh',
};
it('should handle invalid JSON output gracefully', async () => {
const invalidJson = '{ "decision": "allow", incomplete';
mockSpawn.mockStdoutOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data') {
setImmediate(() => callback(Buffer.from(invalidJson)));
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(0));
}
},
);
const result = await hookRunner.executeHook(
commandConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(true);
expect(result.exitCode).toBe(0);
// Should convert plain text to structured output
expect(result.output).toEqual({
decision: 'allow',
systemMessage: invalidJson,
});
});
it('should handle malformed JSON with exit code 0', async () => {
const malformedJson = 'not json at all';
mockSpawn.mockStdoutOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data') {
setImmediate(() => callback(Buffer.from(malformedJson)));
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(0));
}
},
);
const result = await hookRunner.executeHook(
commandConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(true);
expect(result.output).toEqual({
decision: 'allow',
systemMessage: malformedJson,
});
});
it('should handle invalid JSON with exit code 1 (non-blocking error)', async () => {
const invalidJson = '{ broken json';
mockSpawn.mockStderrOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data') {
setImmediate(() => callback(Buffer.from(invalidJson)));
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(1));
}
},
);
const result = await hookRunner.executeHook(
commandConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(false);
expect(result.exitCode).toBe(1);
expect(result.output).toEqual({
decision: 'allow',
systemMessage: `Warning: ${invalidJson}`,
});
});
it('should handle invalid JSON with exit code 2 (blocking error)', async () => {
const invalidJson = '{ "error": incomplete';
mockSpawn.mockStderrOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data') {
setImmediate(() => callback(Buffer.from(invalidJson)));
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(2));
}
},
);
const result = await hookRunner.executeHook(
commandConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(false);
expect(result.exitCode).toBe(2);
expect(result.output).toEqual({
decision: 'deny',
reason: invalidJson,
});
});
it('should handle empty JSON output', async () => {
mockSpawn.mockStdoutOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data') {
setImmediate(() => callback(Buffer.from('')));
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(0));
}
},
);
const result = await hookRunner.executeHook(
commandConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(true);
expect(result.exitCode).toBe(0);
expect(result.output).toBeUndefined();
});
it('should handle double-encoded JSON string', async () => {
const mockOutput = { decision: 'allow', reason: 'All good' };
const doubleEncodedJson = JSON.stringify(JSON.stringify(mockOutput));
mockSpawn.mockStdoutOn.mockImplementation(
(event: string, callback: (data: Buffer) => void) => {
if (event === 'data') {
setImmediate(() => callback(Buffer.from(doubleEncodedJson)));
}
},
);
mockSpawn.mockProcessOn.mockImplementation(
(event: string, callback: (code: number) => void) => {
if (event === 'close') {
setImmediate(() => callback(0));
}
},
);
const result = await hookRunner.executeHook(
commandConfig,
HookEventName.BeforeTool,
mockInput,
);
expect(result.success).toBe(true);
expect(result.output).toEqual(mockOutput);
});
});
});