Download packages/core/src/hooks/hookRunner.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 16.7 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/hooks/hookRunner.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/hooks/hookRunner.ts
-
curl -L -o hookRunner.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/hooks/hookRunner.ts
16.7 kB
| /** | |
| * @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, | |
| GEMINI_PLANS_DIR: this.config.storage.getPlansDir(), | |
| GEMINI_CWD: input.cwd, | |
| GEMINI_SESSION_ID: input.session_id, | |
| 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; | |
| let outputFormat: 'json' | 'text' | 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; | |
| outputFormat = 'json'; | |
| } | |
| } catch { | |
| // Not JSON, convert plain text to structured output | |
| output = this.convertPlainTextToHookOutput( | |
| textToParse, | |
| exitCode || EXIT_CODE_SUCCESS, | |
| ); | |
| outputFormat = 'text'; | |
| } | |
| } | |
| resolve({ | |
| hookConfig, | |
| eventName, | |
| success: exitCode === EXIT_CODE_SUCCESS, | |
| output, | |
| outputFormat, | |
| 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); | |
| const escapedPlansDir = escapeShellArg( | |
| this.config.storage.getPlansDir(), | |
| shellType, | |
| ); | |
| const escapedSessionId = escapeShellArg(input.session_id, shellType); | |
| return command | |
| .replace(/\$GEMINI_PROJECT_DIR/g, () => escapedCwd) | |
| .replace(/\$GEMINI_CWD/g, () => escapedCwd) | |
| .replace(/\$GEMINI_PLANS_DIR/g, () => escapedPlansDir) | |
| .replace(/\$GEMINI_SESSION_ID/g, () => escapedSessionId) | |
| .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 | |
| 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, | |
| }; | |
| } | |
| } | |
| } | |