/** * @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 { 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 { 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 { 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; 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 { const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT; let timeoutHandle: ReturnType | undefined; const controller = new AbortController(); try { // Create a promise that rejects after timeout const timeoutPromise = new Promise((_, 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 { 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, }; } } }