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

546 lines
16 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn, execSync } from 'node:child_process';
import {
HookEventName,
ConfigSource,
HookType,
type HookConfig,
type CommandHookConfig,
type RuntimeHookConfig,
type HookInput,
type HookOutput,
type HookExecutionResult,
type BeforeAgentInput,
type BeforeModelInput,
type BeforeModelOutput,
type BeforeToolInput,
} from './types.js';
import type { Config } from '../config/config.js';
import type { LLMRequest } from './hookTranslator.js';
import { debugLogger } from '../utils/debugLogger.js';
import { sanitizeEnvironment } from '../services/environmentSanitization.js';
import {
escapeShellArg,
getShellConfiguration,
type ShellType,
} from '../utils/shell-utils.js';
/**
* Default timeout for hook execution (60 seconds)
*/
const DEFAULT_HOOK_TIMEOUT = 60000;
/**
* Exit code constants for hook execution
*/
const EXIT_CODE_SUCCESS = 0;
const EXIT_CODE_NON_BLOCKING_ERROR = 1;
/**
* Hook runner that executes command hooks
*/
export class HookRunner {
private readonly config: Config;
constructor(config: Config) {
this.config = config;
}
/**
* Execute a single hook
*/
async executeHook(
hookConfig: HookConfig,
eventName: HookEventName,
input: HookInput,
): Promise<HookExecutionResult> {
const startTime = Date.now();
// Secondary security check: Ensure project hooks are not executed in untrusted folders
if (
hookConfig.source === ConfigSource.Project &&
!this.config.isTrustedFolder()
) {
const errorMessage =
'Security: Blocked execution of project hook in untrusted folder';
debugLogger.warn(errorMessage);
return {
hookConfig,
eventName,
success: false,
error: new Error(errorMessage),
duration: 0,
};
}
try {
if (hookConfig.type === HookType.Runtime) {
return await this.executeRuntimeHook(
hookConfig,
eventName,
input,
startTime,
);
}
return await this.executeCommandHook(
hookConfig,
eventName,
input,
startTime,
);
} catch (error) {
const duration = Date.now() - startTime;
const hookId =
hookConfig.name ||
(hookConfig.type === HookType.Command ? hookConfig.command : '') ||
'unknown';
const errorMessage = `Hook execution failed for event '${eventName}' (hook: ${hookId}): ${error}`;
debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`);
return {
hookConfig,
eventName,
success: false,
error: error instanceof Error ? error : new Error(errorMessage),
duration,
};
}
}
/**
* Execute multiple hooks in parallel
*/
async executeHooksParallel(
hookConfigs: HookConfig[],
eventName: HookEventName,
input: HookInput,
onHookStart?: (config: HookConfig, index: number) => void,
onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void,
): Promise<HookExecutionResult[]> {
const promises = hookConfigs.map(async (config, index) => {
onHookStart?.(config, index);
const result = await this.executeHook(config, eventName, input);
onHookEnd?.(config, result);
return result;
});
return Promise.all(promises);
}
/**
* Execute multiple hooks sequentially
*/
async executeHooksSequential(
hookConfigs: HookConfig[],
eventName: HookEventName,
input: HookInput,
onHookStart?: (config: HookConfig, index: number) => void,
onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void,
): Promise<HookExecutionResult[]> {
const results: HookExecutionResult[] = [];
let currentInput = input;
for (let i = 0; i < hookConfigs.length; i++) {
const config = hookConfigs[i];
onHookStart?.(config, i);
const result = await this.executeHook(config, eventName, currentInput);
onHookEnd?.(config, result);
results.push(result);
// If the hook succeeded and has output, use it to modify the input for the next hook
if (result.success && result.output) {
currentInput = this.applyHookOutputToInput(
currentInput,
result.output,
eventName,
);
}
}
return results;
}
/**
* Apply hook output to modify input for the next hook in sequential execution
*/
private applyHookOutputToInput(
originalInput: HookInput,
hookOutput: HookOutput,
eventName: HookEventName,
): HookInput {
// Create a copy of the original input
const modifiedInput = { ...originalInput };
// Apply modifications based on hook output and event type
if (hookOutput.hookSpecificOutput) {
switch (eventName) {
case HookEventName.BeforeAgent:
if ('additionalContext' in hookOutput.hookSpecificOutput) {
// For BeforeAgent, we could modify the prompt with additional context
const additionalContext =
hookOutput.hookSpecificOutput['additionalContext'];
if (
typeof additionalContext === 'string' &&
'prompt' in modifiedInput
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(modifiedInput as BeforeAgentInput).prompt +=
'\n\n' + additionalContext;
}
}
break;
case HookEventName.BeforeModel:
if ('llm_request' in hookOutput.hookSpecificOutput) {
// For BeforeModel, we update the LLM request
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const hookBeforeModelOutput = hookOutput as BeforeModelOutput;
if (
hookBeforeModelOutput.hookSpecificOutput?.llm_request &&
'llm_request' in modifiedInput
) {
// Merge the partial request with the existing request
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const currentRequest = (modifiedInput as BeforeModelInput)
.llm_request;
const partialRequest =
hookBeforeModelOutput.hookSpecificOutput.llm_request;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(modifiedInput as BeforeModelInput).llm_request = {
...currentRequest,
...partialRequest,
} as LLMRequest;
}
}
break;
case HookEventName.BeforeTool:
if ('tool_input' in hookOutput.hookSpecificOutput) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const newToolInput = hookOutput.hookSpecificOutput[
'tool_input'
] as Record<string, unknown>;
if (newToolInput && 'tool_input' in modifiedInput) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(modifiedInput as BeforeToolInput).tool_input = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(modifiedInput as BeforeToolInput).tool_input,
...newToolInput,
};
}
}
break;
default:
// For other events, no special input modification is needed
break;
}
}
return modifiedInput;
}
/**
* Execute a runtime hook
*/
private async executeRuntimeHook(
hookConfig: RuntimeHookConfig,
eventName: HookEventName,
input: HookInput,
startTime: number,
): Promise<HookExecutionResult> {
const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const controller = new AbortController();
try {
// Create a promise that rejects after timeout
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error(`Hook timed out after ${timeout}ms`)),
timeout,
);
});
// Execute action with timeout race
const result = await Promise.race([
hookConfig.action(input, { signal: controller.signal }),
timeoutPromise,
]);
const output =
result === null || result === undefined ? undefined : result;
return {
hookConfig,
eventName,
success: true,
output,
duration: Date.now() - startTime,
};
} catch (error) {
// Abort the ongoing hook action if it timed out or errored
controller.abort();
return {
hookConfig,
eventName,
success: false,
error: error instanceof Error ? error : new Error(String(error)),
duration: Date.now() - startTime,
};
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
/**
* Execute a command hook
*/
private async executeCommandHook(
hookConfig: CommandHookConfig,
eventName: HookEventName,
input: HookInput,
startTime: number,
): Promise<HookExecutionResult> {
const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT;
return new Promise((resolve) => {
if (!hookConfig.command) {
const errorMessage = 'Command hook missing command';
debugLogger.warn(
`Hook configuration error (non-fatal): ${errorMessage}`,
);
resolve({
hookConfig,
eventName,
success: false,
error: new Error(errorMessage),
duration: Date.now() - startTime,
});
return;
}
let stdout = '';
let stderr = '';
let timedOut = false;
const shellConfig = getShellConfiguration();
let command = this.expandCommand(
hookConfig.command,
input,
shellConfig.shell,
);
if (shellConfig.shell === 'powershell') {
// Append exit code check to ensure the exit code of the command is propagated
command = `${command}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`;
}
// Set up environment variables
const env = {
...sanitizeEnvironment(process.env, this.config.sanitizationConfig),
GEMINI_PROJECT_DIR: input.cwd,
CLAUDE_PROJECT_DIR: input.cwd, // For compatibility
...hookConfig.env,
};
const child = spawn(
shellConfig.executable,
[...shellConfig.argsPrefix, command],
{
env,
cwd: input.cwd,
stdio: ['pipe', 'pipe', 'pipe'],
shell: false,
},
);
// Set up timeout
const timeoutHandle = setTimeout(() => {
timedOut = true;
if (process.platform === 'win32' && child.pid) {
try {
execSync(`taskkill /pid ${child.pid} /f /t`, { timeout: 2000 });
} catch (_e) {
// Ignore errors if process is already dead or access denied
debugLogger.debug(`Taskkill failed: ${_e}`);
}
} else {
child.kill('SIGTERM');
}
// Force kill after 5 seconds
setTimeout(() => {
if (!child.killed) {
if (process.platform === 'win32' && child.pid) {
try {
execSync(`taskkill /pid ${child.pid} /f /t`, { timeout: 2000 });
} catch (_e) {
// Ignore
debugLogger.debug(`Taskkill failed: ${_e}`);
}
} else {
child.kill('SIGKILL');
}
}
}, 5000);
}, timeout);
// Send input to stdin
if (child.stdin) {
child.stdin.on('error', (err: NodeJS.ErrnoException) => {
// Ignore EPIPE errors which happen when the child process closes stdin early
if (err.code !== 'EPIPE') {
debugLogger.debug(`Hook stdin error: ${err}`);
}
});
// Wrap write operations in try-catch to handle synchronous EPIPE errors
// that occur when the child process exits before we finish writing
try {
child.stdin.write(JSON.stringify(input));
child.stdin.end();
} catch (err) {
// Ignore EPIPE errors which happen when the child process closes stdin early
if (err instanceof Error && 'code' in err && err.code !== 'EPIPE') {
debugLogger.debug(`Hook stdin write error: ${err}`);
}
}
}
// Collect stdout
child.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});
// Collect stderr
child.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
});
// Handle process exit
child.on('close', (exitCode) => {
clearTimeout(timeoutHandle);
const duration = Date.now() - startTime;
if (timedOut) {
resolve({
hookConfig,
eventName,
success: false,
error: new Error(`Hook timed out after ${timeout}ms`),
stdout,
stderr,
duration,
});
return;
}
// Parse output
let output: HookOutput | undefined;
const textToParse = stdout.trim() || stderr.trim();
if (textToParse) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
let parsed = JSON.parse(textToParse);
if (typeof parsed === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
parsed = JSON.parse(parsed);
}
if (parsed && typeof parsed === 'object') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
output = parsed as HookOutput;
}
} catch {
// Not JSON, convert plain text to structured output
output = this.convertPlainTextToHookOutput(
textToParse,
exitCode || EXIT_CODE_SUCCESS,
);
}
}
resolve({
hookConfig,
eventName,
success: exitCode === EXIT_CODE_SUCCESS,
output,
stdout,
stderr,
exitCode: exitCode || EXIT_CODE_SUCCESS,
duration,
});
});
// Handle process errors
child.on('error', (error) => {
clearTimeout(timeoutHandle);
const duration = Date.now() - startTime;
resolve({
hookConfig,
eventName,
success: false,
error,
stdout,
stderr,
duration,
});
});
});
}
/**
* Expand command with environment variables and input context
*/
private expandCommand(
command: string,
input: HookInput,
shellType: ShellType,
): string {
debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`);
const escapedCwd = escapeShellArg(input.cwd, shellType);
return command
.replace(/\$GEMINI_PROJECT_DIR/g, () => escapedCwd)
.replace(/\$CLAUDE_PROJECT_DIR/g, () => escapedCwd); // For compatibility
}
/**
* Convert plain text output to structured HookOutput
*/
private convertPlainTextToHookOutput(
text: string,
exitCode: number,
): HookOutput {
if (exitCode === EXIT_CODE_SUCCESS) {
// Success - treat as system message or additional context
return {
decision: 'allow',
systemMessage: text,
};
} else if (exitCode === EXIT_CODE_NON_BLOCKING_ERROR) {
// Non-blocking error (EXIT_CODE_NON_BLOCKING_ERROR = 1)
return {
decision: 'allow',
systemMessage: `Warning: ${text}`,
};
} else {
// All other non-zero exit codes (including 2) are blocking
return {
decision: 'deny',
reason: text,
};
}
}
}