diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md new file mode 100644 index 00000000000..829915d4139 --- /dev/null +++ b/docs/cli/hooks.md @@ -0,0 +1,371 @@ +# Hooks + +Hooks allow you to execute custom shell scripts at specific points in the Gemini +CLI lifecycle. This enables powerful customizations like audit logging, context +injection, tool filtering, and integration with external systems. + +## Quick Start + +### 1. Enable Hooks + +In `~/.gemini/settings.json`: + +```json +{ + "tools": { + "enableHooks": true + } +} +``` + +### 2. Configure a Hook + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "~/.gemini/hooks/my-hook.sh", + "timeout": 10000 + } + ] + } + ] + } +} +``` + +### 3. Create the Hook Script + +```bash +#!/bin/bash +# ~/.gemini/hooks/my-hook.sh + +# Read JSON input from stdin +INPUT=$(cat) + +# Parse event name +EVENT=$(echo "$INPUT" | jq -r '.hook_event_name') + +# Log to file +echo "[$(date)] Event: $EVENT" >> /tmp/gemini_hooks.log + +# Output valid JSON +echo '{"continue": true}' +``` + +Make it executable: + +```bash +chmod +x ~/.gemini/hooks/my-hook.sh +``` + +## Hook Events + +| Event | When It Fires | Key Input Fields | +| --------------------- | --------------------------------- | ------------------------------------------ | +| `SessionStart` | CLI starts up, resumes, or clears | `source` | +| `SessionEnd` | CLI exits | `reason` | +| `BeforeAgent` | Before processing user prompt | `prompt` | +| `AfterAgent` | After agent responds | `prompt`, `prompt_response` | +| `BeforeTool` | Before a tool executes | `tool_name`, `tool_input` | +| `AfterTool` | After a tool completes | `tool_name`, `tool_input`, `tool_response` | +| `PreCompress` | Before context compression | `trigger` | +| `BeforeModel` | Before LLM API call | `llm_request` | +| `AfterModel` | After LLM API response | `llm_request`, `llm_response` | +| `BeforeToolSelection` | Before tool selection | `llm_request` | + +## Hook Input Format + +All hooks receive JSON via stdin with these common fields: + +```json +{ + "session_id": "abc123...", + "transcript_path": "/path/to/session.json", + "cwd": "/current/working/directory", + "hook_event_name": "BeforeAgent", + "timestamp": "2025-01-15T10:30:00.000Z" +} +``` + +Plus event-specific fields (see table above). + +## Hook Output Format + +Hooks should output JSON to stdout: + +```json +{ + "continue": true, + "decision": "allow", + "reason": "Optional reason text", + "systemMessage": "Message to display to user" +} +``` + +### Exit Codes + +| Code | Meaning | +| ---- | -------------------------- | +| `0` | Success | +| `1` | Warning (non-blocking) | +| `2` | Blocking error (deny/stop) | + +## Configuration Structure + +```json +{ + "hooks": { + "[EventName]": [ + { + "hooks": [ + { + "type": "command", + "command": "path/to/script.sh", + "timeout": 60000, + "enabled": true + } + ], + "matcher": "pattern", + "sequential": false + } + ] + } +} +``` + +### Fields + +| Field | Type | Description | +| ------------ | ------- | ---------------------------------------- | +| `type` | string | Always `"command"` | +| `command` | string | Path to script or shell command | +| `timeout` | number | Timeout in milliseconds (default: 60000) | +| `enabled` | boolean | Enable/disable this hook (default: true) | +| `matcher` | string | Pattern for tool-related hooks (regex) | +| `sequential` | boolean | Run hooks sequentially vs parallel | + +## Tool Name Reference + +For `BeforeTool` and `AfterTool` hooks, use these tool names in matchers: + +| Tool | Name in Gemini CLI | +| ----------------- | --------------------- | +| File editing | `replace` | +| File writing | `write_file` | +| File reading | `read_file` | +| Shell commands | `run_shell_command` | +| Todo lists | `write_todos` | +| File globbing | `glob` | +| Content search | `search_file_content` | +| Directory listing | `list_directory` | +| Web fetch | `web_fetch` | +| Web search | `google_web_search` | + +### Matcher Examples + +```json +{ + "AfterTool": [ + { + "hooks": [ + { "type": "command", "command": "~/.gemini/hooks/log-edits.sh" } + ], + "matcher": "replace|write_file" + }, + { + "hooks": [ + { "type": "command", "command": "~/.gemini/hooks/log-shell.sh" } + ], + "matcher": "run_shell_command" + } + ] +} +``` + +## Migrating from Claude Code + +### Automatic Migration + +```bash +gemini hooks migrate +``` + +This command: + +1. Reads your `~/.claude/settings.json` +2. Transforms event names and tool matchers +3. Converts timeouts (seconds → milliseconds) +4. Updates script paths (`.claude/hooks` → `.gemini/hooks`) +5. Saves to `~/.gemini/settings.json` + +### Event Name Mapping + +| Claude Code | Gemini CLI | +| ------------------ | -------------- | +| `UserPromptSubmit` | `BeforeAgent` | +| `PostToolUse` | `AfterTool` | +| `Stop` | `AfterAgent` | +| `PreCompact` | `PreCompress` | +| `SessionStart` | `SessionStart` | +| `SessionEnd` | `SessionEnd` | + +### Tool Name Mapping + +| Claude Code | Gemini CLI | +| ----------- | --------------------- | +| `Edit` | `replace` | +| `MultiEdit` | `replace` | +| `Write` | `write_file` | +| `Read` | `read_file` | +| `Bash` | `run_shell_command` | +| `TodoWrite` | `write_todos` | +| `Glob` | `glob` | +| `Grep` | `search_file_content` | + +### Field Name Differences + +| Concept | Claude Code | Gemini CLI | +| ---------- | ----------- | --------------------- | +| User input | `.message` | `.prompt` | +| Event name | N/A | `.hook_event_name` | +| Tool name | Same | Different (see table) | + +### Compatibility Shim + +For existing Claude Code scripts, use the compatibility shim: + +```bash +#!/bin/bash +source ~/.gemini/hooks/utils/claude-compat-shim.sh + +# Now use normalized variables: +echo "User message: $USER_MESSAGE" +echo "Session ID: $SESSION_ID" +echo "Event: $HOOK_EVENT_NAME" +echo "Tool (normalized): $TOOL_NAME_NORMALIZED" +``` + +The shim provides: + +- `$USER_MESSAGE` - Works with both `.message` (Claude) and `.prompt` (Gemini) +- `$TOOL_NAME_NORMALIZED` - Maps Gemini tool names to Claude-style names +- Helper functions: `hook_success()`, `hook_block()`, `hook_warn()` + +## Examples + +### Audit Logger + +Log all tool executions: + +```bash +#!/bin/bash +# ~/.gemini/hooks/audit-logger.sh + +INPUT=$(cat) +EVENT=$(echo "$INPUT" | jq -r '.hook_event_name') +TOOL=$(echo "$INPUT" | jq -r '.tool_name // "N/A"') + +echo "[$(date)] $EVENT - Tool: $TOOL" >> /tmp/gemini_audit.log +echo '{"continue": true}' +``` + +### Context Injector + +Add project context to prompts: + +```bash +#!/bin/bash +# ~/.gemini/hooks/inject-context.sh + +INPUT=$(cat) + +# Read project context +if [ -f ".gemini/context.md" ]; then + CONTEXT=$(cat .gemini/context.md) + echo "{\"continue\": true, \"hookSpecificOutput\": {\"additionalContext\": \"$CONTEXT\"}}" +else + echo '{"continue": true}' +fi +``` + +### Tool Guardian + +Block dangerous commands: + +```bash +#!/bin/bash +# ~/.gemini/hooks/tool-guard.sh + +INPUT=$(cat) +TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input.command // ""') + +# Block rm -rf commands +if echo "$TOOL_INPUT" | grep -q 'rm.*-rf'; then + echo '{"decision": "block", "reason": "Dangerous rm -rf command blocked"}' + exit 2 +fi + +echo '{"continue": true}' +``` + +## Hook Commands + +Manage hooks with these CLI commands: + +```bash +# List all configured hooks +gemini hooks list + +# Disable a hook +gemini hooks disable "~/.gemini/hooks/my-hook.sh" + +# Enable a disabled hook +gemini hooks enable "~/.gemini/hooks/my-hook.sh" + +# Migrate from Claude Code +gemini hooks migrate +``` + +## Debugging + +### Test Your Hook + +```bash +# Manually test with sample input +echo '{"hook_event_name": "BeforeAgent", "prompt": "test", "session_id": "123", "cwd": "/tmp", "timestamp": "2025-01-01T00:00:00Z"}' | ~/.gemini/hooks/my-hook.sh +``` + +### View Hook Execution + +Check the debug log for hook execution details: + +```bash +# Enable debug mode +export DEBUG=gemini:* + +# Or check hook output in your script +tail -f /tmp/gemini_hooks.log +``` + +### Common Issues + +1. **Hook not firing**: Ensure `tools.enableHooks: true` in settings +2. **"UnknownEvent" logged**: Read event name from JSON stdin, not command args +3. **Permission denied**: Make script executable with `chmod +x` +4. **Timeout errors**: Increase `timeout` value or optimize script +5. **Matcher not working**: Use Gemini tool names (e.g., `replace` not `Edit`) + +## Environment Variables + +Hooks have access to these environment variables: + +| Variable | Description | +| ----------------------------------------- | ------------------------- | +| `GEMINI_PROJECT_DIR` | Current working directory | +| `CLAUDE_PROJECT_DIR` | Same (for compatibility) | +| Plus all existing `process.env` variables | diff --git a/packages/cli/src/commands/hooks/disable.ts b/packages/cli/src/commands/hooks/disable.ts new file mode 100644 index 00000000000..75c629e6169 --- /dev/null +++ b/packages/cli/src/commands/hooks/disable.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +export const disableCommand: CommandModule = { + command: 'disable ', + describe: 'Disable a hook by command name', + handler: async (argv) => { + try { + const commandToDisable = argv['command'] as string; + console.log(`Disabling hook: ${commandToDisable}`); + + const settings = loadSettings(); + + // Get current disabled hooks list + const currentDisabledHooks = settings.merged.disabledHooks || []; + + // Add to disabled list if not already there + if (currentDisabledHooks.includes(commandToDisable)) { + console.log(`Hook "${commandToDisable}" is already disabled.`); + return; + } + + const newDisabledHooks = [...currentDisabledHooks, commandToDisable]; + + // Persist to user settings + settings.setValue(SettingScope.User, 'disabledHooks', newDisabledHooks); + console.log( + `Hook "${commandToDisable}" disabled. This will persist across sessions for all hook sources.`, + ); + } catch (error) { + console.error('Failed to disable hook:', error); + throw error; + } + }, +}; diff --git a/packages/cli/src/commands/hooks/enable.ts b/packages/cli/src/commands/hooks/enable.ts new file mode 100644 index 00000000000..fc6de859c9c --- /dev/null +++ b/packages/cli/src/commands/hooks/enable.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enable a hook by command name', + handler: async (argv) => { + try { + const commandToEnable = argv['command'] as string; + console.log(`Enabling hook: ${commandToEnable}`); + + const settings = loadSettings(); + + // Get current disabled hooks list + const currentDisabledHooks = settings.merged.disabledHooks || []; + + // Remove from disabled list if present + if (!currentDisabledHooks.includes(commandToEnable)) { + console.log(`Hook "${commandToEnable}" is already enabled.`); + return; + } + + const newDisabledHooks = currentDisabledHooks.filter( + (cmd) => cmd !== commandToEnable, + ); + + // Persist to user settings + settings.setValue(SettingScope.User, 'disabledHooks', newDisabledHooks); + console.log( + `Hook "${commandToEnable}" enabled. This will persist across sessions for all hook sources.`, + ); + } catch (error) { + console.error('Failed to enable hook:', error); + throw error; + } + }, +}; diff --git a/packages/cli/src/commands/hooks/index.ts b/packages/cli/src/commands/hooks/index.ts new file mode 100644 index 00000000000..94f87ca77ca --- /dev/null +++ b/packages/cli/src/commands/hooks/index.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { migrateCommand } from './migrate.js'; +import { listCommand } from './list.js'; +import { enableCommand } from './enable.js'; +import { disableCommand } from './disable.js'; +import { installCommand } from './install.js'; +import { uninstallCommand } from './uninstall.js'; + +export const hooksCommand: CommandModule = { + command: 'hooks ', + describe: 'Manage hooks', + builder: (yargs) => + yargs + .command(migrateCommand) + .command(listCommand) + .command(enableCommand) + .command(disableCommand) + .command(installCommand) + .command(uninstallCommand) + .demandCommand(), + handler: () => {}, +}; diff --git a/packages/cli/src/commands/hooks/install.ts b/packages/cli/src/commands/hooks/install.ts new file mode 100644 index 00000000000..1c42dcaa0f2 --- /dev/null +++ b/packages/cli/src/commands/hooks/install.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { loadSettings } from '../../config/settings.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import type { ExtensionInstallMetadata } from '@google/gemini-cli-core'; + +export const installCommand: CommandModule = { + command: 'install ', + describe: 'Install a hook plugin (as an extension)', + handler: async (argv) => { + const source = argv['source'] as string; + console.log(`Installing hook plugin from: ${source}`); + + const settings = loadSettings(); + const extensionManager = new ExtensionManager({ + workspaceDir: process.cwd(), + settings: settings.merged, + requestConsent: requestConsentNonInteractive, + requestSetting: null, + }); + + await extensionManager.loadExtensions(); + + let type: ExtensionInstallMetadata['type']; + if (source.startsWith('.') || source.startsWith('/')) { + type = 'local'; + } else if (source.startsWith('http')) { + type = 'git'; // Default to git for URLs + } else { + console.error( + `Could not determine source type for "${source}". Please provide a local path or a git URL.`, + ); + return; + } + + const metadata: ExtensionInstallMetadata = { + type, + source, + }; + + try { + const extension = + await extensionManager.installOrUpdateExtension(metadata); + console.log(`Successfully installed plugin: ${extension.name}`); + + if (extension.hooks) { + const hookCount = Object.values(extension.hooks).flat().length; + console.log(`Registered ${hookCount} hooks.`); + } else { + console.warn('No hooks found in this extension.'); + } + } catch (error) { + console.error('Failed to install plugin:', error); + throw error; + } + }, +}; diff --git a/packages/cli/src/commands/hooks/list.ts b/packages/cli/src/commands/hooks/list.ts new file mode 100644 index 00000000000..b49afe3be59 --- /dev/null +++ b/packages/cli/src/commands/hooks/list.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; + +export const listCommand: CommandModule = { + command: 'list', + describe: 'List configured hooks from settings', + handler: async () => { + try { + const settings = loadSettings(); + const hooksConfig = settings.merged.hooks; + + if (!hooksConfig || Object.keys(hooksConfig).length === 0) { + console.log('No hooks configured.'); + return; + } + + console.log(`Active configuration source: ${settings.user.path}`); + console.log(''); + + for (const [eventName, definitions] of Object.entries(hooksConfig)) { + console.log(`Event: ${eventName}`); + if (Array.isArray(definitions)) { + for (const def of definitions) { + if (def.matcher) { + console.log(` Matcher: ${def.matcher}`); + } + for (const hook of def.hooks) { + console.log(` - Type: ${hook.type}`); + if (hook.type === 'command') { + console.log(` Command: ${hook.command}`); + } + } + } + } + console.log(''); + } + } catch (error) { + console.error('Failed to list hooks:', error); + throw error; + } + }, +}; diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts new file mode 100644 index 00000000000..c9cddc43262 --- /dev/null +++ b/packages/cli/src/commands/hooks/migrate.ts @@ -0,0 +1,270 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; +import { + HookEventName, + HookType, + type HookConfig, + type HookDefinition, +} from '@google/gemini-cli-core'; +import type { CommandModule } from 'yargs'; +import stripJsonComments from 'strip-json-comments'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +const CLAUDE_SETTINGS_PATH = path.join(homedir(), '.claude', 'settings.json'); + +interface ClaudeHookConfig { + type: string; + command: string; + timeout?: number; +} + +interface ClaudeHookDefinition { + hooks: ClaudeHookConfig[]; + matcher?: string; +} + +interface ClaudeSettings { + hooks?: Record; +} + +const EVENT_MAPPING: Record = { + SessionStart: HookEventName.SessionStart, + SessionEnd: HookEventName.SessionEnd, + PreCompact: HookEventName.PreCompress, + UserPromptSubmit: HookEventName.BeforeAgent, + PostToolUse: HookEventName.AfterTool, + Stop: HookEventName.AfterAgent, +}; + +/** + * Tool name mappings from Claude Code to Gemini CLI + * Used to transform matcher patterns in hook configurations + */ +const TOOL_NAME_MAPPING: Record = { + // File operations + Edit: 'replace', + MultiEdit: 'replace', + Write: 'write_file', + Read: 'read_file', + Glob: 'glob', + Grep: 'search_file_content', + + // Shell and system + Bash: 'run_shell_command', + + // Task management + TodoWrite: 'write_todos', + + // Web operations + WebFetch: 'web_fetch', + WebSearch: 'google_web_search', +}; + +/** + * Transform a Claude Code matcher pattern to Gemini CLI format + * Handles patterns like "Edit|MultiEdit|Write" -> "replace|write_file" + */ +function transformMatcher(matcher: string | undefined): string | undefined { + if (!matcher || matcher === '*') return matcher; + + // Split by | and transform each tool name + const tools = matcher.split('|').map((tool) => tool.trim()); + const transformedTools = tools.map((tool) => TOOL_NAME_MAPPING[tool] || tool); + + // Remove duplicates (e.g., Edit|MultiEdit both map to 'replace') + const uniqueTools = [...new Set(transformedTools)]; + + return uniqueTools.join('|'); +} + +export const migrateCommand: CommandModule = { + command: 'migrate', + describe: 'Migrate hooks from Claude Code configuration', + builder: (yargs) => + yargs.option('from-claude', { + type: 'boolean', + description: 'Migrate from Claude Code settings', + default: true, + }), + handler: async () => { + console.log('Migrating hooks from Claude Code...'); + + if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) { + console.error( + `Claude settings file not found at ${CLAUDE_SETTINGS_PATH}`, + ); + return; + } + + try { + const claudeContent = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf-8'); + const claudeSettings = JSON.parse( + stripJsonComments(claudeContent), + ) as ClaudeSettings; + + if (!claudeSettings.hooks) { + console.log('No hooks found in Claude configuration.'); + return; + } + + const geminiHooks: Record = {}; + + for (const [claudeEvent, definitions] of Object.entries( + claudeSettings.hooks, + )) { + const geminiEvent = EVENT_MAPPING[claudeEvent]; + if (!geminiEvent) { + console.warn(`Skipping unknown Claude event: ${claudeEvent}`); + continue; + } + + // Warn about semantic difference for Stop → AfterAgent mapping + if (claudeEvent === 'Stop') { + console.warn(`⚠️ Note: Stop → AfterAgent has different semantics:`); + console.warn( + ` - Claude's "Stop" was a notification when the agent finished`, + ); + console.warn( + ` - Gemini's "AfterAgent" can force the agent to continue if it returns 'block'`, + ); + console.warn( + ` If your hooks expect finality, ensure they return 'allow' or no decision.`, + ); + } + + geminiHooks[geminiEvent] = definitions.map((def) => { + const hooks = def.hooks.map((hook): HookConfig => { + let command = hook.command; + // Replace ~/.claude/hooks with ~/.gemini/hooks + if (command.includes('.claude/hooks')) { + command = command.replace('.claude/hooks', '.gemini/hooks'); + } + + // Warn about scripts that may need updates for field differences + if (command.endsWith('.sh') || command.endsWith('.py')) { + const scriptPath = command.replace(/^~/, homedir()); + if (fs.existsSync(scriptPath)) { + try { + const scriptContent = fs.readFileSync(scriptPath, 'utf-8'); + // Check for Claude-specific field access patterns + if ( + scriptContent.includes('.message') && + !scriptContent.includes('.prompt') + ) { + console.warn( + ` ⚠️ Warning: ${command} uses '.message' - Gemini uses '.prompt' for user input`, + ); + } + if (scriptContent.includes('tool_name')) { + console.warn( + ` ℹ️ Note: ${command} accesses tool_name - tool names differ between Claude and Gemini`, + ); + console.warn( + ` Consider using ~/.gemini/hooks/utils/claude-compat-shim.sh for compatibility`, + ); + } + } catch { + // Ignore read errors + } + } + } + + return { + type: HookType.Command, + command, + // Claude timeouts are in seconds; converting to milliseconds for Gemini. + timeout: hook.timeout ? hook.timeout * 1000 : undefined, + }; + }); + + // Transform matcher to use Gemini tool names + const transformedMatcher = transformMatcher(def.matcher); + if (def.matcher && transformedMatcher !== def.matcher) { + console.log( + ` 📝 Transformed matcher: "${def.matcher}" → "${transformedMatcher}"`, + ); + } + + return { + hooks, + matcher: transformedMatcher, + }; + }); + } + + // Load existing Gemini settings + const loadedSettings = loadSettings(); + const userSettings = loadedSettings.user; + + // Merge hooks using upsert mechanism (truly idempotent) + // Use a deep copy to ensure we don't mutate the loaded settings directly + const newHooks = JSON.parse( + JSON.stringify(userSettings.settings.hooks || {}), + ); + for (const [event, definitions] of Object.entries(geminiHooks)) { + const eventName = event as HookEventName; + if (!newHooks[eventName]) { + newHooks[eventName] = []; + } + const existingEventDefinitions = newHooks[eventName]; + + for (const newDef of definitions) { + const newCommandSet = new Set(newDef.hooks.map((h) => h.command)); + if (newCommandSet.size === 0) continue; + + const existingDefIndex = existingEventDefinitions.findIndex( + (d: HookDefinition) => d.matcher === newDef.matcher, + ); + + if (existingDefIndex !== -1) { + // A definition with the same matcher exists. Additive merge to avoid data loss. + const existingDef = existingEventDefinitions[existingDefIndex]; + for (const newHook of newDef.hooks) { + // Add the new hook only if a hook with the same command doesn't already exist. + if ( + !existingDef.hooks.some( + (h: HookConfig) => h.command === newHook.command, + ) + ) { + existingDef.hooks.push(newHook); + } + } + } else { + // No definition with this matcher, so add it as a new one. + existingEventDefinitions.push(newDef); + } + } + } + + // Enable hooks in tools settings + const existingTools = userSettings.settings.tools || {}; + const newTools = { ...existingTools, enableHooks: true }; + + // Save settings using the robust setValue API which handles originalSettings update and saving + loadedSettings.setValue(SettingScope.User, 'hooks', newHooks); + loadedSettings.setValue(SettingScope.User, 'tools', newTools); + + console.log( + '\n✅ Successfully migrated hooks to ~/.gemini/settings.json', + ); + console.log(` Enabled ${Object.keys(geminiHooks).length} hook events.`); + console.log('\n📚 Important differences to note:'); + console.log(' • User input: Claude uses .message, Gemini uses .prompt'); + console.log(' • Tool names differ (see tool mapping above)'); + console.log(' • Timeouts: converted from seconds to milliseconds'); + console.log( + '\n💡 Tip: Source ~/.gemini/hooks/utils/claude-compat-shim.sh in your scripts for compatibility', + ); + } catch (error) { + console.error('Failed to migrate hooks:', error); + throw error; + } + }, +}; diff --git a/packages/cli/src/commands/hooks/uninstall.ts b/packages/cli/src/commands/hooks/uninstall.ts new file mode 100644 index 00000000000..8b6160bcca3 --- /dev/null +++ b/packages/cli/src/commands/hooks/uninstall.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { loadSettings } from '../../config/settings.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; + +export const uninstallCommand: CommandModule = { + command: 'uninstall ', + describe: 'Uninstall a hook plugin', + handler: async (argv) => { + const name = argv['name'] as string; + console.log(`Uninstalling hook plugin: ${name}`); + + const settings = loadSettings(); + const extensionManager = new ExtensionManager({ + workspaceDir: process.cwd(), + settings: settings.merged, + requestConsent: requestConsentNonInteractive, + requestSetting: null, + }); + + await extensionManager.loadExtensions(); + + try { + await extensionManager.uninstallExtension(name, false); + console.log(`Successfully uninstalled plugin: ${name}`); + } catch (error) { + console.error('Failed to uninstall plugin:', error); + throw error; + } + }, +}; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7e4f1cd4d03..13d3aa3ffae 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -10,6 +10,7 @@ import process from 'node:process'; import { mcpCommand } from '../commands/mcp.js'; import type { OutputFormat } from '@google/gemini-cli-core'; import { extensionsCommand } from '../commands/extensions.js'; +import { hooksCommand } from '../commands/hooks/index.js'; import { Config, setGeminiMdFilename as setServerGeminiMdFilename, @@ -280,6 +281,8 @@ export async function parseArguments(settings: Settings): Promise { yargsInstance.command(extensionsCommand); } + yargsInstance.command(hooksCommand); + yargsInstance .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') @@ -666,6 +669,7 @@ export async function loadCliConfig( // TODO: loading of hooks based on workspace trust enableHooks: settings.tools?.enableHooks ?? false, hooks: settings.hooks || {}, + disabledHooks: settings.disabledHooks || [], }); } diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index e6467d9b966..52e49e24d0c 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -508,6 +508,7 @@ export class ExtensionManager extends ExtensionLoader { contextFiles, installMetadata, mcpServers: config.mcpServers, + hooks: config.hooks, excludeTools: config.excludeTools, isActive: this.extensionEnablementManager.isEnabled( config.name, diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index bafaba59a8e..00ea58aad7b 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -7,6 +7,8 @@ import type { MCPServerConfig, ExtensionInstallMetadata, + HookDefinition, + HookEventName, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -24,6 +26,7 @@ export interface ExtensionConfig { name: string; version: string; mcpServers?: Record; + hooks?: { [K in HookEventName]?: HookDefinition[] }; contextFileName?: string | string[]; excludeTools?: string[]; settings?: ExtensionSetting[]; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d098b8cd88c..222041421be 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1419,6 +1419,16 @@ const SETTINGS_SCHEMA = { showInDialog: false, mergeStrategy: MergeStrategy.SHALLOW_MERGE, }, + disabledHooks: { + type: 'array', + label: 'Disabled Hooks', + category: 'Advanced', + requiresRestart: false, + default: [] as string[], + description: + 'List of hook command names that are disabled. This persists across sessions for all hook sources (user config, extensions, etc.).', + showInDialog: false, + }, } as const satisfies SettingsSchema; export type SettingsSchemaType = typeof SETTINGS_SCHEMA; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 5366a4ef70b..f703cc8e8d0 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -131,6 +131,9 @@ vi.mock('./config/config.js', () => ({ getSandbox: vi.fn(() => false), getQuestion: vi.fn(() => ''), isInteractive: () => false, + getHookRegistry: vi.fn(() => null), + getHookSystem: vi.fn(() => null), + getDisabledHooks: vi.fn(() => []), } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), @@ -269,6 +272,9 @@ describe('gemini.tsx main function', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + getHookRegistry: vi.fn(() => null), + getHookSystem: vi.fn(() => null), + getDisabledHooks: vi.fn(() => []), } as unknown as Config; }); vi.mocked(loadSettings).mockReturnValue({ @@ -501,6 +507,9 @@ describe('gemini.tsx main function kitty protocol', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + getHookRegistry: vi.fn(() => null), + getHookSystem: vi.fn(() => null), + getDisabledHooks: vi.fn(() => []), } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ errors: [], @@ -756,6 +765,7 @@ describe('gemini.tsx main function kitty protocol', () => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getUsageStatisticsEnabled: () => false, + getHookSystem: () => null, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false); @@ -984,6 +994,7 @@ describe('gemini.tsx main function kitty protocol', () => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getUsageStatisticsEnabled: () => false, + getHookSystem: () => null, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mock('./utils/readStdin.js', () => ({ @@ -1142,6 +1153,7 @@ describe('gemini.tsx main function exit codes', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + getHookSystem: () => null, } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ merged: { security: { auth: {} }, ui: {} }, @@ -1203,6 +1215,7 @@ describe('gemini.tsx main function exit codes', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + getHookSystem: () => null, } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ merged: { security: { auth: {} }, ui: {} }, @@ -1266,6 +1279,9 @@ describe('startInteractiveUI', () => { getProjectRoot: () => '/root', getScreenReader: () => false, getDebugMode: () => false, + getHookRegistry: vi.fn(() => null), + getHookSystem: vi.fn(() => null), + getDisabledHooks: vi.fn(() => []), } as unknown as Config; const mockSettings = { merged: { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index e61f3ff364a..eaab0d0ed75 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -57,6 +57,8 @@ import { disableLineWrapping, shouldEnterAlternateScreen, ExitCodes, + SessionStartSource, + SessionEndReason, } from '@google/gemini-cli-core'; import { initializeApp, @@ -571,6 +573,33 @@ export async function main() { await config.initialize(); + // Hook: SessionStart - use HookSystem if available + const hookSystem = config.getHookSystem(); + if (hookSystem) { + try { + const source = argv.resume + ? SessionStartSource.Resume + : SessionStartSource.Startup; + await hookSystem.getEventHandler().fireSessionStartEvent(source); + } catch (error) { + // Log the error but don't block startup + debugLogger.error('Error executing SessionStart hooks:', error); + } + } + + // Register SessionEnd hook + registerCleanup(async () => { + if (hookSystem) { + try { + await hookSystem + .getEventHandler() + .fireSessionEndEvent(SessionEndReason.Exit); + } catch (error) { + debugLogger.error('Error executing SessionEnd hooks:', error); + } + } + }); + // If not a TTY, read from stdin // This is for cases where the user pipes input directly into the command if (!process.stdin.isTTY) { diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index d0bc8b4ff95..66323ebbecf 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -21,6 +21,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; +import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; import { initCommand } from '../ui/commands/initCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; @@ -56,6 +57,16 @@ export class BuiltinCommandLoader implements ICommandLoader { * @returns A promise that resolves to an array of `SlashCommand` objects. */ async loadCommands(_signal: AbortSignal): Promise { + let ideCmd: SlashCommand | null = null; + try { + ideCmd = await ideCommand(); + } catch (_e) { + // This can happen if the IDE client fails to initialize. + // We don't want to crash the whole CLI if this happens. + // The error is likely already logged by the IDE client or will be logged here. + // debugLogger.warn('Failed to load ideCommand:', _e); + } + const allDefinitions: Array = [ aboutCommand, authCommand, @@ -70,7 +81,8 @@ export class BuiltinCommandLoader implements ICommandLoader { editorCommand, extensionsCommand(this.config?.getEnableExtensionReloading()), helpCommand, - await ideCommand(), + hooksCommand, + ideCmd, initCommand, mcpCommand, memoryCommand, diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts new file mode 100644 index 00000000000..054c6a31d28 --- /dev/null +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import { SettingScope } from '../../config/settings.js'; + +export const hooksCommand: SlashCommand = { + name: 'hooks', + description: 'Manage hooks (list, enable, disable, reload)', + kind: CommandKind.BUILT_IN, + action: async (context, args) => { + try { + const config = context.services.config; + if (!config) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: 'Configuration not loaded.', + }, + Date.now(), + ); + return; + } + + const registry = config.getHookRegistry(); + if (!registry) { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'Hook registry not initialized.', + }, + Date.now(), + ); + return; + } + + const parts = args.trim().split(/\s+/); + const subCommand = parts[0]; + const target = parts.slice(1).join(' '); + + if (subCommand === 'reload') { + await registry.initialize(); + context.ui.addItem( + { + type: MessageType.INFO, + text: 'Hooks reloaded.', + }, + Date.now(), + ); + return; + } + + if (subCommand === 'enable' || subCommand === 'disable') { + if (!target) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Usage: /hooks ${subCommand} `, + }, + Date.now(), + ); + return; + } + const enabled = subCommand === 'enable'; + + // Update in-memory state immediately for this session + registry.setHookEnabled(target, enabled); + + // Persist the change to disabled hooks list + const settings = context.services.settings; + const currentDisabledHooks = settings.merged.disabledHooks || []; + + let newDisabledHooks: string[]; + if (enabled) { + // Remove from disabled list + newDisabledHooks = currentDisabledHooks.filter( + (cmd) => cmd !== target, + ); + } else { + // Add to disabled list if not already there + newDisabledHooks = currentDisabledHooks.includes(target) + ? currentDisabledHooks + : [...currentDisabledHooks, target]; + } + + // Persist to disk + settings.setValue(SettingScope.User, 'disabledHooks', newDisabledHooks); + + context.ui.addItem( + { + type: MessageType.INFO, + text: `Hook "${target}" ${enabled ? 'enabled' : 'disabled'}. This will persist across sessions for all hook sources.`, + }, + Date.now(), + ); + return; + } + + // List (default) + const hooks = registry.getAllHooks(); + if (hooks.length === 0) { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'No hooks configured.', + }, + Date.now(), + ); + return; + } + + const lines = ['## Active Hooks']; + + // Group by event name + const byEvent: Record = {}; + for (const hook of hooks) { + if (!byEvent[hook.eventName]) { + byEvent[hook.eventName] = []; + } + byEvent[hook.eventName].push(hook); + } + + for (const [event, entries] of Object.entries(byEvent)) { + lines.push(`\n### ${event}`); + for (const entry of entries) { + const command = entry.config.command || '(plugin)'; + const status = entry.enabled ? '✅ Enabled' : '❌ Disabled'; + const matcher = entry.matcher ? ` (matcher: "${entry.matcher}")` : ''; + lines.push(`- ${status}: \`${command}\` [${entry.source}]${matcher}`); + } + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: lines.join('\n'), + }, + Date.now(), + ); + } catch (error) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `An error occurred while managing hooks: ${error instanceof Error ? error.message : String(error)}`, + }, + Date.now(), + ); + } + }, + completion: async (_context, _partialArg) => [ + 'list', + 'enable', + 'disable', + 'reload', + ], +}; diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 01f437b1da4..867bea035a3 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -84,6 +84,8 @@ const mockConfig = { isInteractive: () => false, getExperiments: () => {}, getEnableHooks: () => false, + getHookRegistry: vi.fn(() => null), + getDisabledHooks: vi.fn(() => []), } as unknown as Config; mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus()); mockConfig.getHookSystem = vi.fn().mockReturnValue(new HookSystem(mockConfig)); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5e25ae1ae13..40cbaa3f9a2 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -33,6 +33,7 @@ import { WebSearchTool } from '../tools/web-search.js'; import { GeminiClient } from '../core/client.js'; import { BaseLlmClient } from '../core/baseLlmClient.js'; import type { HookDefinition, HookEventName } from '../hooks/types.js'; +import { HookRegistry } from '../hooks/hookRegistry.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { GitService } from '../services/gitService.js'; import type { TelemetryTarget } from '../telemetry/index.js'; @@ -308,6 +309,7 @@ export interface ConfigParameters { hooks?: { [K in HookEventName]?: HookDefinition[]; }; + disabledHooks?: string[]; previewFeatures?: boolean; enableModelAvailabilityService?: boolean; } @@ -421,6 +423,8 @@ export class Config { private readonly hooks: | { [K in HookEventName]?: HookDefinition[] } | undefined; + private readonly disabledHooks: string[]; + readonly hookRegistry: HookRegistry; private experiments: Experiments | undefined; private experimentsPromise: Promise | undefined; private hookSystem?: HookSystem; @@ -567,6 +571,8 @@ export class Config { this.retryFetchErrors = params.retryFetchErrors ?? false; this.disableYoloMode = params.disableYoloMode ?? false; this.hooks = params.hooks; + this.disabledHooks = params.disabledHooks ?? []; + this.hookRegistry = new HookRegistry(this); this.experiments = params.experiments; if (params.contextFileName) { @@ -640,6 +646,7 @@ export class Config { await Promise.all([ await this.mcpClientManager.startConfiguredMcpServers(), await this.getExtensionLoader().start(this), + await this.hookRegistry.initialize(), ]); // Initialize hook system if enabled @@ -931,6 +938,10 @@ export class Config { return this.mcpServers; } + getHookRegistry(): HookRegistry { + return this.hookRegistry; + } + getMcpClientManager(): McpClientManager | undefined { return this.mcpClientManager; } @@ -1522,6 +1533,13 @@ export class Config { return this.hooks; } + /** + * Get list of disabled hook command names + */ + getDisabledHooks(): string[] { + return this.disabledHooks; + } + /** * Get experiments configuration */ diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts index 3ee9c8d43e6..912bbb75c3a 100644 --- a/packages/core/src/hooks/hookEventHandler.test.ts +++ b/packages/core/src/hooks/hookEventHandler.test.ts @@ -101,7 +101,7 @@ describe('HookEventHandler', () => { totalDuration: 100, }; - vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ + vi.mocked(mockHookPlanner.createExecutionPlan).mockResolvedValue({ eventName: HookEventName.BeforeTool, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, @@ -138,7 +138,7 @@ describe('HookEventHandler', () => { }); it('should return empty result when no hooks to execute', async () => { - vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(null); + vi.mocked(mockHookPlanner.createExecutionPlan).mockResolvedValue(null); const result = await hookEventHandler.fireBeforeToolEvent('EditTool', {}); @@ -149,9 +149,9 @@ describe('HookEventHandler', () => { }); it('should handle execution errors gracefully', async () => { - vi.mocked(mockHookPlanner.createExecutionPlan).mockImplementation(() => { - throw new Error('Planning failed'); - }); + vi.mocked(mockHookPlanner.createExecutionPlan).mockRejectedValue( + new Error('Planning failed'), + ); const result = await hookEventHandler.fireBeforeToolEvent('EditTool', {}); @@ -192,7 +192,7 @@ describe('HookEventHandler', () => { totalDuration: 100, }; - vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ + vi.mocked(mockHookPlanner.createExecutionPlan).mockResolvedValue({ eventName: HookEventName.BeforeTool, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, @@ -257,7 +257,7 @@ describe('HookEventHandler', () => { totalDuration: 100, }; - vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ + vi.mocked(mockHookPlanner.createExecutionPlan).mockResolvedValue({ eventName: HookEventName.BeforeTool, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, @@ -315,7 +315,7 @@ describe('HookEventHandler', () => { totalDuration: 50, }; - vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ + vi.mocked(mockHookPlanner.createExecutionPlan).mockResolvedValue({ eventName: HookEventName.Notification, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, @@ -378,7 +378,7 @@ describe('HookEventHandler', () => { totalDuration: 200, }; - vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ + vi.mocked(mockHookPlanner.createExecutionPlan).mockResolvedValue({ eventName: HookEventName.SessionStart, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, @@ -441,7 +441,7 @@ describe('HookEventHandler', () => { totalDuration: 150, }; - vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ + vi.mocked(mockHookPlanner.createExecutionPlan).mockResolvedValue({ eventName: HookEventName.BeforeModel, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, @@ -493,7 +493,7 @@ describe('HookEventHandler', () => { }, ]; - vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ + vi.mocked(mockHookPlanner.createExecutionPlan).mockResolvedValue({ eventName: HookEventName.BeforeTool, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index 67b61e3588a..ed580d04298 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -444,7 +444,10 @@ export class HookEventHandler { ): Promise { try { // Create execution plan - const plan = this.hookPlanner.createExecutionPlan(eventName, context); + const plan = await this.hookPlanner.createExecutionPlan( + eventName, + context, + ); if (!plan || plan.hookConfigs.length === 0) { return { diff --git a/packages/core/src/hooks/hookPlanner.test.ts b/packages/core/src/hooks/hookPlanner.test.ts index c408266ecb9..fe45446c2b7 100644 --- a/packages/core/src/hooks/hookPlanner.test.ts +++ b/packages/core/src/hooks/hookPlanner.test.ts @@ -37,15 +37,17 @@ describe('HookPlanner', () => { }); describe('createExecutionPlan', () => { - it('should return empty plan when no hooks registered', () => { + it('should return empty plan when no hooks registered', async () => { vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue([]); - const plan = hookPlanner.createExecutionPlan(HookEventName.BeforeTool); + const plan = await hookPlanner.createExecutionPlan( + HookEventName.BeforeTool, + ); expect(plan).toBeNull(); }); - it('should create plan for hooks without matchers', () => { + it('should create plan for hooks without matchers', async () => { const mockEntries: HookRegistryEntry[] = [ { config: { type: HookType.Command, command: './hook1.sh' }, @@ -66,7 +68,9 @@ describe('HookPlanner', () => { vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue(mockEntries); - const plan = hookPlanner.createExecutionPlan(HookEventName.BeforeTool); + const plan = await hookPlanner.createExecutionPlan( + HookEventName.BeforeTool, + ); expect(plan).not.toBeNull(); expect(plan!.hookConfigs).toHaveLength(2); @@ -74,7 +78,7 @@ describe('HookPlanner', () => { expect(plan!.hookConfigs[1].command).toBe('./test-hook.sh'); }); - it('should filter hooks by tool name matcher', () => { + it('should filter hooks by tool name matcher', async () => { const mockEntries: HookRegistryEntry[] = [ { config: { type: HookType.Command, command: './edit_hook.sh' }, @@ -94,15 +98,18 @@ describe('HookPlanner', () => { vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue(mockEntries); // Test with EditTool context - const plan = hookPlanner.createExecutionPlan(HookEventName.BeforeTool, { - toolName: 'EditTool', - }); + const plan = await hookPlanner.createExecutionPlan( + HookEventName.BeforeTool, + { + toolName: 'EditTool', + }, + ); expect(plan).not.toBeNull(); expect(plan!.hookConfigs).toHaveLength(2); // Both should match (one specific, one general) }); - it('should filter hooks by regex matcher', () => { + it('should filter hooks by regex matcher', async () => { const mockEntries: HookRegistryEntry[] = [ { config: { type: HookType.Command, command: './edit_hook.sh' }, @@ -123,7 +130,7 @@ describe('HookPlanner', () => { vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue(mockEntries); // Test with EditTool - should match first hook - const editPlan = hookPlanner.createExecutionPlan( + const editPlan = await hookPlanner.createExecutionPlan( HookEventName.BeforeTool, { toolName: 'EditTool' }, ); @@ -132,7 +139,7 @@ describe('HookPlanner', () => { expect(editPlan!.hookConfigs[0].command).toBe('./edit_hook.sh'); // Test with WriteTool - should match first hook - const writePlan = hookPlanner.createExecutionPlan( + const writePlan = await hookPlanner.createExecutionPlan( HookEventName.BeforeTool, { toolName: 'WriteTool' }, ); @@ -141,7 +148,7 @@ describe('HookPlanner', () => { expect(writePlan!.hookConfigs[0].command).toBe('./edit_hook.sh'); // Test with ReadTool - should match second hook - const readPlan = hookPlanner.createExecutionPlan( + const readPlan = await hookPlanner.createExecutionPlan( HookEventName.BeforeTool, { toolName: 'ReadTool' }, ); @@ -150,14 +157,14 @@ describe('HookPlanner', () => { expect(readPlan!.hookConfigs[0].command).toBe('./read_hook.sh'); // Test with unmatched tool - should match no hooks - const otherPlan = hookPlanner.createExecutionPlan( + const otherPlan = await hookPlanner.createExecutionPlan( HookEventName.BeforeTool, { toolName: 'OtherTool' }, ); expect(otherPlan).toBeNull(); }); - it('should handle wildcard matcher', () => { + it('should handle wildcard matcher', async () => { const mockEntries: HookRegistryEntry[] = [ { config: { type: HookType.Command, command: './wildcard_hook.sh' }, @@ -170,15 +177,18 @@ describe('HookPlanner', () => { vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue(mockEntries); - const plan = hookPlanner.createExecutionPlan(HookEventName.BeforeTool, { - toolName: 'AnyTool', - }); + const plan = await hookPlanner.createExecutionPlan( + HookEventName.BeforeTool, + { + toolName: 'AnyTool', + }, + ); expect(plan).not.toBeNull(); expect(plan!.hookConfigs).toHaveLength(1); }); - it('should handle empty string matcher', () => { + it('should handle empty string matcher', async () => { const mockEntries: HookRegistryEntry[] = [ { config: { @@ -194,15 +204,18 @@ describe('HookPlanner', () => { vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue(mockEntries); - const plan = hookPlanner.createExecutionPlan(HookEventName.BeforeTool, { - toolName: 'AnyTool', - }); + const plan = await hookPlanner.createExecutionPlan( + HookEventName.BeforeTool, + { + toolName: 'AnyTool', + }, + ); expect(plan).not.toBeNull(); expect(plan!.hookConfigs).toHaveLength(1); }); - it('should handle invalid regex matcher gracefully', () => { + it('should handle invalid regex matcher gracefully', async () => { const mockEntries: HookRegistryEntry[] = [ { config: { @@ -219,15 +232,18 @@ describe('HookPlanner', () => { vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue(mockEntries); // Should match when toolName exactly equals the invalid regex pattern - const plan = hookPlanner.createExecutionPlan(HookEventName.BeforeTool, { - toolName: '[invalid-regex', - }); + const plan = await hookPlanner.createExecutionPlan( + HookEventName.BeforeTool, + { + toolName: '[invalid-regex', + }, + ); expect(plan).not.toBeNull(); expect(plan!.hookConfigs).toHaveLength(1); // Should fall back to exact match // Should not match when toolName doesn't exactly equal the pattern - const planNoMatch = hookPlanner.createExecutionPlan( + const planNoMatch = await hookPlanner.createExecutionPlan( HookEventName.BeforeTool, { toolName: 'other-tool', @@ -237,7 +253,7 @@ describe('HookPlanner', () => { expect(planNoMatch).toBeNull(); }); - it('should deduplicate identical hooks', () => { + it('should deduplicate identical hooks', async () => { const mockEntries: HookRegistryEntry[] = [ { config: { type: HookType.Command, command: './same_hook.sh' }, @@ -273,7 +289,9 @@ describe('HookPlanner', () => { vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue(mockEntries); - const plan = hookPlanner.createExecutionPlan(HookEventName.BeforeTool); + const plan = await hookPlanner.createExecutionPlan( + HookEventName.BeforeTool, + ); expect(plan).not.toBeNull(); expect(plan!.hookConfigs).toHaveLength(2); // Should be deduplicated to 2 unique hooks @@ -282,7 +300,7 @@ describe('HookPlanner', () => { ); }); - it('should match trigger for session events', () => { + it('should match trigger for session events', async () => { const mockEntries: HookRegistryEntry[] = [ { config: { type: HookType.Command, command: './startup_hook.sh' }, @@ -303,7 +321,7 @@ describe('HookPlanner', () => { vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue(mockEntries); // Test startup trigger - const startupPlan = hookPlanner.createExecutionPlan( + const startupPlan = await hookPlanner.createExecutionPlan( HookEventName.SessionStart, { trigger: 'startup' }, ); @@ -312,7 +330,7 @@ describe('HookPlanner', () => { expect(startupPlan!.hookConfigs[0].command).toBe('./startup_hook.sh'); // Test resume trigger - const resumePlan = hookPlanner.createExecutionPlan( + const resumePlan = await hookPlanner.createExecutionPlan( HookEventName.SessionStart, { trigger: 'resume' }, ); diff --git a/packages/core/src/hooks/hookPlanner.ts b/packages/core/src/hooks/hookPlanner.ts index 4a0f00a8e19..3553aec334f 100644 --- a/packages/core/src/hooks/hookPlanner.ts +++ b/packages/core/src/hooks/hookPlanner.ts @@ -8,6 +8,114 @@ import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js'; import type { HookExecutionPlan } from './types.js'; import type { HookEventName } from './types.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { Worker } from 'node:worker_threads'; + +// Inline worker code for regex testing to prevent ReDoS attacks +const workerCode = ` +import { parentPort } from 'node:worker_threads'; + +if (!parentPort) { + throw new Error('This code must be run as a worker thread'); +} + +parentPort.on('message', ({ pattern, testString }) => { + try { + const regex = new RegExp(pattern); + const result = regex.test(testString); + parentPort.postMessage({ success: true, result }); + } catch (error) { + parentPort.postMessage({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } +}); +`; + +/** + * Safely tests a string against a user-provided regex pattern to prevent + * ReDoS (Regular Expression Denial of Service) attacks. + * + * This function executes the regex test in an isolated worker thread with a hard + * timeout. If the regex takes too long (catastrophic backtracking), the worker + * is terminated and the pattern is rejected. This provides robust protection + * against malicious or poorly-written regex patterns from untrusted hook sources. + * + * @param pattern - The regex pattern from user configuration + * @param testString - The string to test against the pattern + * @param timeoutMs - Maximum time to allow regex execution (default: 100ms) + * @returns Promise resolving to true if pattern matches, false otherwise + */ +async function safeRegexTest( + pattern: string, + testString: string, + timeoutMs = 100, +): Promise { + // Quick checks before spinning up a worker + if (pattern === '*') return true; + if (pattern === testString) return true; + + return new Promise((resolve) => { + let worker: Worker; + let resolved = false; + + try { + // Create worker from inline code + // Cast to any because @types/node might be missing 'type' in WorkerOptions + // eslint-disable-next-line @typescript-eslint/no-explicit-any + worker = new Worker(workerCode, { eval: true, type: 'module' } as any); + } catch (error) { + // If worker creation fails, fall back to exact match + debugLogger.warn( + `Failed to create worker for regex test: ${error}. Falling back to exact match.`, + ); + resolve(pattern === testString); + return; + } + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + worker.terminate(); + debugLogger.warn( + `Regex pattern '${pattern}' timed out after ${timeoutMs}ms. Falling back to exact match.`, + ); + resolve(pattern === testString); + } + }, timeoutMs); + + worker.on('message', (msg) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + worker.terminate(); + + if (msg.success) { + resolve(msg.result); + } else { + debugLogger.warn( + `Failed to compile regex pattern '${pattern}': ${msg.error}. Falling back to exact match.`, + ); + resolve(pattern === testString); + } + } + }); + + worker.on('error', (error) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + worker.terminate(); + debugLogger.warn( + `Worker error testing regex pattern '${pattern}': ${error}. Falling back to exact match.`, + ); + resolve(pattern === testString); + } + }); + + worker.postMessage({ pattern, testString }); + }); +} /** * Hook planner that selects matching hooks and creates execution plans @@ -22,19 +130,23 @@ export class HookPlanner { /** * Create execution plan for a hook event */ - createExecutionPlan( + async createExecutionPlan( eventName: HookEventName, context?: HookEventContext, - ): HookExecutionPlan | null { + ): Promise { const hookEntries = this.hookRegistry.getHooksForEvent(eventName); if (hookEntries.length === 0) { return null; } - // Filter hooks by matcher - const matchingEntries = hookEntries.filter((entry) => - this.matchesContext(entry, context), + // Filter hooks by matcher (evaluate all matchers in parallel with ReDoS protection) + const matchResults = await Promise.all( + hookEntries.map((entry) => this.matchesContext(entry, context)), + ); + + const matchingEntries = hookEntries.filter( + (_, index) => matchResults[index], ); if (matchingEntries.length === 0) { @@ -68,10 +180,10 @@ export class HookPlanner { /** * Check if a hook entry matches the given context */ - private matchesContext( + private async matchesContext( entry: HookRegistryEntry, context?: HookEventContext, - ): boolean { + ): Promise { if (!entry.matcher || !context) { return true; // No matcher means match all } @@ -97,16 +209,14 @@ export class HookPlanner { /** * Match tool name against matcher pattern + * Uses worker thread isolation to prevent ReDoS attacks */ - private matchesToolName(matcher: string, toolName: string): boolean { - try { - // Attempt to treat the matcher as a regular expression. - const regex = new RegExp(matcher); - return regex.test(toolName); - } catch { - // If it's not a valid regex, treat it as a literal string for an exact match. - return matcher === toolName; - } + private async matchesToolName( + matcher: string, + toolName: string, + ): Promise { + // Use safe regex test with worker thread protection + return safeRegexTest(matcher, toolName); } /** diff --git a/packages/core/src/hooks/hookRegistry.test.ts b/packages/core/src/hooks/hookRegistry.test.ts index 5c6906b8387..093821ee194 100644 --- a/packages/core/src/hooks/hookRegistry.test.ts +++ b/packages/core/src/hooks/hookRegistry.test.ts @@ -50,6 +50,7 @@ describe('HookRegistry', () => { storage: mockStorage, getExtensions: vi.fn().mockReturnValue([]), getHooks: vi.fn().mockReturnValue({}), + getDisabledHooks: vi.fn().mockReturnValue([]), } as unknown as Config; hookRegistry = new HookRegistry(mockConfig); diff --git a/packages/core/src/hooks/hookRegistry.ts b/packages/core/src/hooks/hookRegistry.ts index 82fda329754..20b548cf355 100644 --- a/packages/core/src/hooks/hookRegistry.ts +++ b/packages/core/src/hooks/hookRegistry.ts @@ -202,13 +202,18 @@ export class HookRegistry { typeof hookConfig === 'object' && this.validateHookConfig(hookConfig, eventName, source) ) { + // Check if this hook is in the disabled list + const disabledHooks = this.config.getDisabledHooks(); + const isDisabled = + hookConfig.command && disabledHooks.includes(hookConfig.command); + this.entries.push({ config: hookConfig, source, eventName, matcher: definition.matcher, sequential: definition.sequential, - enabled: true, + enabled: hookConfig.enabled !== false && !isDisabled, }); } else { // Invalid hooks are logged and discarded here, they won't reach HookRunner diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index 4678b0d2141..7a0c3c6de3f 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -41,6 +41,7 @@ export interface CommandHookConfig { type: HookType.Command; command: string; timeout?: number; + enabled?: boolean; } export type HookConfig = CommandHookConfig; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d3c711473a2..a288cb1f824 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -148,6 +148,9 @@ export * from './test-utils/index.js'; // Export hook types export * from './hooks/types.js'; +export * from './hooks/hookRunner.js'; +export * from './hooks/hookRegistry.js'; +export * from './hooks/hookTranslator.js'; // Export stdio utils export * from './utils/stdio.js';