diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index 8709b22d105..1f49a0c12b2 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -64,7 +64,7 @@ Command hooks execute commands via child processes. Input JSON is passed through "hooks": { "PreToolUse": [ { - "matcher": "WriteFile", + "matcher": "write_file", "hooks": [ { "type": "command", @@ -215,7 +215,7 @@ When `ok` is `false`, Qwen Code will continue working and use the `reason` as co "hooks": { "PreToolUse": [ { - "matcher": "Bash", + "matcher": "run_shell_command", "hooks": [ { "type": "prompt", @@ -235,43 +235,45 @@ When `ok` is `false`, Qwen Code will continue working and use the `reason` as co Hooks fire at specific points during a Qwen Code session. Different events support different matchers to filter trigger conditions. -| Event | Triggered When | Matcher Target | -| :------------------- | :---------------------------------------- | :-------------------------------------------------------- | -| `PreToolUse` | Before tool execution | Tool name (`WriteFile`, `ReadFile`, `Bash`, etc.) | -| `PostToolUse` | After successful tool execution | Tool name | -| `PostToolUseFailure` | After tool execution fails | Tool name | -| `UserPromptSubmit` | After user submits prompt | None (always fires) | -| `SessionStart` | When session starts or resumes | Source (`startup`, `resume`, `clear`, `compact`) | -| `SessionEnd` | When session ends | Reason (`clear`, `logout`, `prompt_input_exit`, etc.) | -| `Stop` | When Claude prepares to conclude response | None (always fires) | -| `SubagentStart` | When subagent starts | Agent type (`Bash`, `Explorer`, `Plan`, etc.) | -| `SubagentStop` | When subagent stops | Agent type | -| `PreCompact` | Before conversation compaction | Trigger (`manual`, `auto`) | -| `Notification` | When notifications are sent | Type (`permission_prompt`, `idle_prompt`, `auth_success`) | -| `PermissionRequest` | When permission dialog is shown | Tool name | -| `TodoCreated` | When a new todo item is created | None (always fires) | -| `TodoCompleted` | When a todo item is marked as completed | None (always fires) | +| Event | Triggered When | Matcher Target | +| :------------------- | :---------------------------------------- | :------------------------------------------------------------- | +| `PreToolUse` | Before tool execution | Tool id (`write_file`, `read_file`, `run_shell_command`, etc.) | +| `PostToolUse` | After successful tool execution | Tool id | +| `PostToolUseFailure` | After tool execution fails | Tool id | +| `UserPromptSubmit` | After user submits prompt | None (always fires) | +| `SessionStart` | When session starts or resumes | Source (`startup`, `resume`, `clear`, `compact`) | +| `SessionEnd` | When session ends | Reason (`clear`, `logout`, `prompt_input_exit`, etc.) | +| `Stop` | When Claude prepares to conclude response | None (always fires) | +| `SubagentStart` | When subagent starts | Agent type (`Bash`, `Explorer`, `Plan`, etc.) | +| `SubagentStop` | When subagent stops | Agent type | +| `PreCompact` | Before conversation compaction | Trigger (`manual`, `auto`) | +| `Notification` | When notifications are sent | Type (`permission_prompt`, `idle_prompt`, `auth_success`) | +| `PermissionRequest` | When permission dialog is shown | Tool id | +| `PermissionDenied` | When tool permission is denied | Tool id | +| `TodoCreated` | When a new todo item is created | None (always fires) | +| `TodoCompleted` | When a todo item is marked as completed | None (always fires) | ### Matcher Patterns `matcher` is a regular expression used to filter trigger conditions. -| Event Type | Events | Matcher Support | Matcher Target | -| :------------------ | :--------------------------------------------------------------------- | :-------------- | :------------------------------------------------------- | -| Tool Events | `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest` | ✅ Regex | Tool name: `WriteFile`, `ReadFile`, `Bash`, etc. | -| Subagent Events | `SubagentStart`, `SubagentStop` | ✅ Regex | Agent type: `Bash`, `Explorer`, etc. | -| Session Events | `SessionStart` | ✅ Regex | Source: `startup`, `resume`, `clear`, `compact` | -| Session Events | `SessionEnd` | ✅ Regex | Reason: `clear`, `logout`, `prompt_input_exit`, etc. | -| Notification Events | `Notification` | ✅ Exact match | Type: `permission_prompt`, `idle_prompt`, `auth_success` | -| Compact Events | `PreCompact` | ✅ Exact match | Trigger: `manual`, `auto` | -| Todo Events | `TodoCreated`, `TodoCompleted` | ❌ No | N/A | -| Prompt Events | `UserPromptSubmit` | ❌ No | N/A | -| Stop Events | `Stop` | ❌ No | N/A | +| Event Type | Events | Matcher Support | Matcher Target | +| :------------------ | :----------------------------------------------------------------------------------------- | :-------------- | :------------------------------------------------------------ | +| Tool Events | `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied` | ✅ Regex | Tool id: `write_file`, `read_file`, `run_shell_command`, etc. | +| Subagent Events | `SubagentStart`, `SubagentStop` | ✅ Regex | Agent type: `Bash`, `Explorer`, etc. | +| Session Events | `SessionStart` | ✅ Regex | Source: `startup`, `resume`, `clear`, `compact` | +| Session Events | `SessionEnd` | ✅ Regex | Reason: `clear`, `logout`, `prompt_input_exit`, etc. | +| Notification Events | `Notification` | ✅ Exact match | Type: `permission_prompt`, `idle_prompt`, `auth_success` | +| Compact Events | `PreCompact` | ✅ Exact match | Trigger: `manual`, `auto` | +| Todo Events | `TodoCreated`, `TodoCompleted` | ❌ No | N/A | +| Prompt Events | `UserPromptSubmit` | ❌ No | N/A | +| Stop Events | `Stop` | ❌ No | N/A | **Matcher Syntax:** - Empty string `""` or `"*"` matches all events of that type -- Standard regex syntax supported (e.g., `^Bash$`, `Read.*`, `(WriteFile|Edit)`) +- Standard regex syntax supported (e.g., `^run_shell_command$`, `read_.*`, `(write_file|edit)`) +- Tool hooks receive the runtime tool id in `tool_name` (for example, `write_file`). Built-in display names such as `WriteFile` and `ReadFile` are also accepted as matcher aliases for compatibility, but new configs should prefer runtime ids. **Examples:** @@ -280,7 +282,7 @@ Hooks fire at specific points during a Qwen Code session. Different events suppo "hooks": { "PreToolUse": [ { - "matcher": "^Bash$", + "matcher": "^run_shell_command$", "hooks": [ { "type": "command", @@ -289,7 +291,7 @@ Hooks fire at specific points during a Qwen Code session. Different events suppo ] }, { - "matcher": "Write.*", + "matcher": "write_.*", "hooks": [ { "type": "command", @@ -1075,7 +1077,7 @@ Hooks are configured in Qwen Code settings, typically in `.qwen/settings.json` o "hooks": { "PreToolUse": [ { - "matcher": "^Bash$", + "matcher": "^run_shell_command$", "sequential": false, "hooks": [ { @@ -1128,7 +1130,7 @@ Only `command` type supports asynchronous execution. Setting `"async": true` run "hooks": { "PostToolUse": [ { - "matcher": "WriteFile|Edit", + "matcher": "write_file|edit", "hooks": [ { "type": "command", diff --git a/packages/core/src/hooks/hookPlanner.test.ts b/packages/core/src/hooks/hookPlanner.test.ts index fbc5d2b1f35..5a9bf02c41d 100644 --- a/packages/core/src/hooks/hookPlanner.test.ts +++ b/packages/core/src/hooks/hookPlanner.test.ts @@ -6,7 +6,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js'; -import { getHookMatcherTarget, HookPlanner } from './hookPlanner.js'; +import { + getHookMatcherTarget, + getToolMatcherTargets, + HookPlanner, +} from './hookPlanner.js'; import { HookEventName, HookType, HooksConfigSource } from './types.js'; describe('HookPlanner', () => { @@ -352,6 +356,114 @@ describe('HookPlanner', () => { expect(result).not.toBeNull(); }); + it('matches built-in tool display names against runtime tool ids', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'WriteFile', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'write_file', + }); + + expect(result).not.toBeNull(); + }); + + it('matches pipe-separated display names against runtime tool ids', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'WriteFile|Edit', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'write_file', + }); + + expect(result).not.toBeNull(); + }); + + it('matches legacy tool aliases against runtime tool ids', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'SearchFiles', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'grep_search', + }); + + expect(result).not.toBeNull(); + }); + + it('matches legacy runtime aliases against runtime tool ids', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'search_file_content', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'grep_search', + }); + + expect(result).not.toBeNull(); + }); + + it('does not match regex against tool aliases', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'Edit', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'notebook_edit', + }); + + expect(result).toBeNull(); + }); + + it('does not let alias expansion bypass runtime id regex exclusions', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '^(?!write_file).*$', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'write_file', + }); + + expect(result).toBeNull(); + }); + + it('passes through unknown tool ids without aliases', () => { + expect(getToolMatcherTargets('computer_use__click')).toEqual([ + 'computer_use__click', + ]); + }); + it('should not match tool name with different exact string', () => { const entry: HookRegistryEntry = { config: { type: HookType.Command, command: 'echo test' }, diff --git a/packages/core/src/hooks/hookPlanner.ts b/packages/core/src/hooks/hookPlanner.ts index eb5fa368fd6..94df2ef3d19 100644 --- a/packages/core/src/hooks/hookPlanner.ts +++ b/packages/core/src/hooks/hookPlanner.ts @@ -8,9 +8,14 @@ import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js'; import type { HookExecutionPlan } from './types.js'; import { getHookKey, HookEventName } from './types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { getAliasSetForTool } from '../utils/tool-utils.js'; const debugLogger = createDebugLogger('TRUSTED_HOOKS'); +export function getToolMatcherTargets(toolName: string): string[] { + return [...getAliasSetForTool(toolName)]; +} + type HookMatcherTargetKind = | 'toolName' | 'commandName' @@ -240,16 +245,33 @@ export class HookPlanner { * Match tool name against matcher pattern */ private matchesToolName(matcher: string, toolName: string): boolean { + const targets = getToolMatcherTargets(toolName); + + if ( + matcher.includes('|') && + !matcher.startsWith('^') && + !matcher.startsWith('(') + ) { + const alternatives = matcher.split('|').map((entry) => entry.trim()); + if (alternatives.some((entry) => targets.includes(entry))) { + return true; + } + } + + if (targets.includes(matcher)) { + return true; + } + try { - // Attempt to treat the matcher as a regular expression. + // Regex matchers apply to the runtime id only. Alias expansion is exact + // so display names do not create new substring matches. const regex = new RegExp(matcher); return regex.test(toolName); } catch (error) { - // If it's not a valid regex, treat it as a literal string for an exact match. debugLogger.warn( - `Invalid regex in hook matcher "${matcher}" for tool "${toolName}", falling back to exact match: ${error}`, + `Invalid regex in hook matcher "${matcher}" for tool "${toolName}", no alias match and invalid regex: ${error}`, ); - return matcher === toolName; + return false; } } diff --git a/packages/core/src/hooks/sessionHooksManager.test.ts b/packages/core/src/hooks/sessionHooksManager.test.ts index f70d16496d2..adf26b7f735 100644 --- a/packages/core/src/hooks/sessionHooksManager.test.ts +++ b/packages/core/src/hooks/sessionHooksManager.test.ts @@ -287,6 +287,86 @@ describe('SessionHooksManager', () => { ).toBe(0); }); + it('matches built-in tool display names against runtime tool ids', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'WriteFile', + callback, + 'Test error', + ); + + const matching = manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'write_file', + ); + + expect(matching.length).toBe(1); + }); + + it('matches pipe-separated display names against runtime tool ids', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'WriteFile|Edit', + callback, + 'Test error', + ); + + const matching = manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'write_file', + ); + + expect(matching.length).toBe(1); + }); + + it('does not match regex against tool aliases', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Edit', + callback, + 'Test error', + ); + + const matching = manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'notebook_edit', + ); + + expect(matching.length).toBe(0); + }); + + it('does not let alias expansion bypass runtime id regex exclusions', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + '^(?!write_file).*$', + callback, + 'Test error', + ); + + const matching = manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'write_file', + ); + + expect(matching.length).toBe(0); + }); + it('should not match different tool name', () => { const callback = vi.fn().mockResolvedValue({ continue: true }); diff --git a/packages/core/src/hooks/sessionHooksManager.ts b/packages/core/src/hooks/sessionHooksManager.ts index 64168cf8edd..586bde3fc3d 100644 --- a/packages/core/src/hooks/sessionHooksManager.ts +++ b/packages/core/src/hooks/sessionHooksManager.ts @@ -6,18 +6,22 @@ import { createDebugLogger } from '../utils/debugLogger.js'; import type { - HookEventName, CommandHookConfig, HttpHookConfig, FunctionHookConfig, FunctionHookCallback, HookConfig, HookExecutionResult, -} from './types.js'; + HookEventName} from './types.js'; import { HookType } from './types.js'; +import { getHookMatcherTarget, getToolMatcherTargets } from './hookPlanner.js'; const debugLogger = createDebugLogger('SESSION_HOOKS_MANAGER'); +function isToolMatcherEvent(event: HookEventName): boolean { + return getHookMatcherTarget(event)?.kind === 'toolName'; +} + /** * Generate a unique hook ID */ @@ -271,7 +275,31 @@ export class SessionHooksManager { target: string, ): SessionHookEntry[] { const hooks = this.getHooksForEvent(sessionId, event); - return hooks.filter((entry) => this.matchesPattern(entry.matcher, target)); + + return hooks.filter((entry) => { + if (isToolMatcherEvent(event)) { + return this.matchesToolPattern(entry.matcher, target); + } + return this.matchesPattern(entry.matcher, target); + }); + } + + private matchesToolPattern(pattern: string, toolName: string): boolean { + if ( + pattern.includes('|') && + !pattern.startsWith('^') && + !pattern.startsWith('(') + ) { + const alternatives = pattern.split('|').map((s) => s.trim()); + return alternatives.some((alt) => this.matchesToolPattern(alt, toolName)); + } + + const targets = getToolMatcherTargets(toolName); + if (targets.includes(pattern)) { + return true; + } + + return this.matchesPattern(pattern, toolName); } /** diff --git a/packages/core/src/utils/tool-utils.ts b/packages/core/src/utils/tool-utils.ts index fb6f52c75e9..251b00f3e50 100644 --- a/packages/core/src/utils/tool-utils.ts +++ b/packages/core/src/utils/tool-utils.ts @@ -59,8 +59,8 @@ const TOOL_ALIAS_MAP: Map> = (() => { return map; })(); -const getAliasSetForTool = (toolName: ToolName): Set => { - const aliases = TOOL_ALIAS_MAP.get(toolName); +export const getAliasSetForTool = (toolName: string): Set => { + const aliases = TOOL_ALIAS_MAP.get(toolName as ToolName); if (!aliases) { return new Set([normalizeIdentifier(toolName)]); }