From b5115e731e7aa6de376f05afed639d3544f827d2 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Thu, 16 Apr 2026 10:10:33 +0800 Subject: [PATCH] feat(hooks): Add HTTP Hook, Function Hook and Async Hook support (#2827) * add http/async/function type * fix url error * resolve comment * align cc non blocking error * fix hookRunner for async * fix(hooks): update hook type validation to support http and function types - Change validated hook types from ['command', 'plugin'] to ['command', 'http', 'function'] - Add validation for HTTP hooks requiring url field - Add validation for function hooks requiring callback field - Add comprehensive test coverage for all hook type validations Co-authored-by: Qwen-Coder * fix(hooks): align SSRF protection with Claude Code behavior - Allow 127.0.0.0/8 (loopback) for local dev hooks - Allow localhost hostname for local dev hooks - Allow ::1 (IPv6 loopback) for local dev hooks - Add 100.64.0.0/10 (CGNAT) to blocked ranges (RFC 6598) - Update tests to match Claude Code's ssrfGuard.ts behavior This fixes HTTP hooks failing to connect to local dev servers. Co-authored-by: Qwen-Coder * refactor(hooks): align HTTP hook security with Claude Code behavior - Add CRLF/NUL sanitization for env var interpolation (header injection) - Implement combined abort signal (external signal + timeout) - Upgrade SSRF protection to DNS-level with ssrfGuard - Allow loopback (127.0.0.0/8, ::1) for local dev hooks - Block CGNAT (100.64.0.0/10) and IPv6 private ranges - Increase default HTTP hook timeout to 10 minutes - Fix VS Code hooks schema to support http type - Add url, headers, allowedEnvVars, async, once, statusMessage, shell fields - Note: "function" type is SDK-only (callback cannot be serialized to JSON) * feat(hooks): enhance Function Hook with messages, skillRoot, shell, and matcher support - Add MessagesProvider for automatic conversation history passing to function hooks - Add FunctionHookContext with messages, toolUseID, and signal - Add skillRoot support for skill-scoped session hooks - Add shell parameter support for command hooks (bash/powershell) - Add regex matcher support for hook pattern matching - Add statusMessage to CommandHookConfig - Change default function hook timeout from 60s to 5s - Add comprehensive unit tests for all new features Co-authored-by: Qwen-Coder * add session hook for skill * fix function hook parsing * refactor ui for http hook/async hook/function hook * update doc and add integration test * change telemetryn type and refactor SSRF * fix project level bug --------- Co-authored-by: Qwen-Coder --- docs/users/features/hooks.md | 450 +++++-- .../hook-integration/hooks-advanced.test.ts | 1139 +++++++++++++++++ .../hook-integration/mockHttpServer.ts | 254 ++++ packages/cli/src/acp-integration/acpAgent.ts | 12 +- packages/cli/src/commands/auth/handler.ts | 5 + packages/cli/src/config/config.ts | 14 +- packages/cli/src/config/settings.ts | 20 + packages/cli/src/config/settingsSchema.ts | 63 +- packages/cli/src/gemini.test.tsx | 8 + packages/cli/src/gemini.tsx | 10 + packages/cli/src/i18n/locales/de.js | 1 + packages/cli/src/i18n/locales/ja.js | 1 + packages/cli/src/i18n/locales/pt.js | 1 + packages/cli/src/i18n/locales/ru.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + .../cli/src/ui/commands/hooksCommand.test.ts | 16 - packages/cli/src/ui/commands/hooksCommand.ts | 107 +- .../ui/components/hooks/HookDetailStep.tsx | 30 +- .../hooks/HooksManagementDialog.test.tsx | 20 +- .../hooks/HooksManagementDialog.tsx | 52 +- .../cli/src/ui/components/hooks/constants.ts | 1 + packages/cli/src/ui/components/hooks/types.ts | 1 + packages/core/src/config/config.ts | 62 +- .../src/extension/claude-converter.test.ts | 7 +- .../src/extension/extensionManager.test.ts | 60 +- packages/core/src/extension/variables.test.ts | 61 +- .../core/src/hooks/asyncHookRegistry.test.ts | 517 ++++++++ packages/core/src/hooks/asyncHookRegistry.ts | 371 ++++++ .../src/hooks/combinedAbortSignal.test.ts | 91 ++ .../core/src/hooks/combinedAbortSignal.ts | 52 + .../core/src/hooks/envInterpolator.test.ts | 197 +++ packages/core/src/hooks/envInterpolator.ts | 133 ++ .../core/src/hooks/functionHookRunner.test.ts | 432 +++++++ packages/core/src/hooks/functionHookRunner.ts | 257 ++++ .../core/src/hooks/hookEventHandler.test.ts | 151 +++ packages/core/src/hooks/hookEventHandler.ts | 77 +- packages/core/src/hooks/hookPlanner.test.ts | 2 +- packages/core/src/hooks/hookRegistry.test.ts | 277 +++- packages/core/src/hooks/hookRegistry.ts | 94 +- packages/core/src/hooks/hookRunner.test.ts | 69 + packages/core/src/hooks/hookRunner.ts | 335 ++++- packages/core/src/hooks/hookSystem.test.ts | 25 +- packages/core/src/hooks/hookSystem.ts | 212 ++- .../core/src/hooks/httpHookRunner.test.ts | 292 +++++ packages/core/src/hooks/httpHookRunner.ts | 426 ++++++ packages/core/src/hooks/index.ts | 23 + .../core/src/hooks/registerSkillHooks.test.ts | 229 ++++ packages/core/src/hooks/registerSkillHooks.ts | 152 +++ .../src/hooks/sessionHooksManager.test.ts | 694 ++++++++++ .../core/src/hooks/sessionHooksManager.ts | 369 ++++++ packages/core/src/hooks/ssrfGuard.test.ts | 159 +++ packages/core/src/hooks/ssrfGuard.ts | 286 +++++ packages/core/src/hooks/trustedHooks.ts | 8 +- packages/core/src/hooks/types.ts | 144 ++- packages/core/src/hooks/urlValidator.test.ts | 148 +++ packages/core/src/hooks/urlValidator.ts | 162 +++ packages/core/src/index.ts | 2 +- .../core/src/skills/skill-manager.test.ts | 149 +++ packages/core/src/skills/skill-manager.ts | 140 ++ packages/core/src/skills/types.ts | 22 + packages/core/src/telemetry/types.ts | 7 +- packages/core/src/tools/skill.ts | 37 + .../schemas/settings.schema.json | 632 +++++++-- 63 files changed, 9301 insertions(+), 469 deletions(-) create mode 100644 integration-tests/hook-integration/hooks-advanced.test.ts create mode 100644 integration-tests/hook-integration/mockHttpServer.ts create mode 100644 packages/core/src/hooks/asyncHookRegistry.test.ts create mode 100644 packages/core/src/hooks/asyncHookRegistry.ts create mode 100644 packages/core/src/hooks/combinedAbortSignal.test.ts create mode 100644 packages/core/src/hooks/combinedAbortSignal.ts create mode 100644 packages/core/src/hooks/envInterpolator.test.ts create mode 100644 packages/core/src/hooks/envInterpolator.ts create mode 100644 packages/core/src/hooks/functionHookRunner.test.ts create mode 100644 packages/core/src/hooks/functionHookRunner.ts create mode 100644 packages/core/src/hooks/httpHookRunner.test.ts create mode 100644 packages/core/src/hooks/httpHookRunner.ts create mode 100644 packages/core/src/hooks/registerSkillHooks.test.ts create mode 100644 packages/core/src/hooks/registerSkillHooks.ts create mode 100644 packages/core/src/hooks/sessionHooksManager.test.ts create mode 100644 packages/core/src/hooks/sessionHooksManager.ts create mode 100644 packages/core/src/hooks/ssrfGuard.test.ts create mode 100644 packages/core/src/hooks/ssrfGuard.ts create mode 100644 packages/core/src/hooks/urlValidator.test.ts create mode 100644 packages/core/src/hooks/urlValidator.ts diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index 84aa8ce04..821ddb771 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -1,4 +1,4 @@ -# Qwen Code Hooks Documentation +# Qwen Code Hooks ## Overview @@ -28,48 +28,225 @@ Hooks are user-defined scripts or programs that are automatically executed by Qw - Integrate with external systems and services - Modify tool inputs or responses programmatically -## Hook Architecture +## Hook Types -The Qwen Code hook system consists of several key components: +Qwen Code supports three hook executor types: -1. **Hook Registry**: Stores and manages all configured hooks -2. **Hook Planner**: Determines which hooks should run for each event -3. **Hook Runner**: Executes individual hooks with proper context -4. **Hook Aggregator**: Combines results from multiple hooks -5. **Hook Event Handler**: Coordinates the firing of hooks for events +| Type | Description | +| :--------- | :--------------------------------------------------------------------------------------------- | +| `command` | Execute a shell command. Receives JSON via `stdin`, returns results via `stdout`. | +| `http` | Send JSON as a `POST` request body to a specified URL. Returns results via HTTP response body. | +| `function` | Directly call a registered JavaScript function (session-level hooks only). | + +### Command Hooks + +Command hooks execute commands via child processes. Input JSON is passed through stdin, and output is returned via stdout. + +**Configuration:** + +| Field | Type | Required | Description | +| :-------------- | :----------------------- | :------- | :------------------------------------------ | +| `type` | `"command"` | Yes | Hook type | +| `command` | `string` | Yes | Command to execute | +| `name` | `string` | No | Hook name (for logging) | +| `description` | `string` | No | Hook description | +| `timeout` | `number` | No | Timeout in milliseconds, default 60000 | +| `async` | `boolean` | No | Whether to run asynchronously in background | +| `env` | `Record` | No | Environment variables | +| `shell` | `"bash" \| "powershell"` | No | Shell to use | +| `statusMessage` | `string` | No | Status message displayed during execution | + +**Example:** + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "WriteFile", + "hooks": [ + { + "type": "command", + "command": "$QWEN_PROJECT_DIR/.qwen/hooks/security-check.sh", + "name": "security-check", + "timeout": 10000 + } + ] + } + ] + } +} +``` + +### HTTP Hooks + +HTTP hooks send hook input as POST requests to specified URLs. They support URL whitelists, DNS-level SSRF protection, environment variable interpolation, and other security features. + +**Configuration:** + +| Field | Type | Required | Description | +| :--------------- | :----------------------- | :------- | :-------------------------------------------------------- | +| `type` | `"http"` | Yes | Hook type | +| `url` | `string` | Yes | Target URL | +| `headers` | `Record` | No | Request headers (supports env var interpolation) | +| `allowedEnvVars` | `string[]` | No | Whitelist of environment variables allowed in URL/headers | +| `timeout` | `number` | No | Timeout in seconds, default 600 | +| `name` | `string` | No | Hook name (for logging) | +| `statusMessage` | `string` | No | Status message displayed during execution | +| `once` | `boolean` | No | Execute only once per event per session (HTTP hooks only) | + +**Security Features:** + +- **URL Whitelist**: Configure allowed URL patterns via `allowedUrls` +- **SSRF Protection**: Blocks private IPs (10.x.x.x, 172.16-31.x.x, 192.168.x.x, etc.) but allows loopback addresses (127.0.0.1, ::1) +- **DNS Validation**: Validates domain resolution before requests to prevent DNS rebinding attacks +- **Environment Variable Interpolation**: `${VAR}` syntax, only allows variables in `allowedEnvVars` whitelist + +**Example:** + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "http", + "url": "http://127.0.0.1:8080/hooks/pre-tool-use", + "headers": { + "Authorization": "Bearer ${HOOK_API_KEY}" + }, + "allowedEnvVars": ["HOOK_API_KEY"], + "timeout": 10, + "name": "remote-security-check" + } + ] + } + ] + } +} +``` + +### Function Hooks + +Function hooks directly call registered JavaScript/TypeScript functions. They are used internally by the Skill system and are not currently exposed as a public API for end users. + +**Note**: For most use cases, use **command hooks** or **HTTP hooks** instead, which can be configured in settings files. ## Hook Events -Hooks fire at specific points during a Qwen Code session. When an event fires and a matcher matches, Qwen Code passes JSON context about the event to your hook handler. For command hooks, input arrives on stdin. Your handler can inspect the input, take action, and optionally return a decision. Some events fire once per session, while others fire repeatedly inside the agentic loop. +Hooks fire at specific points during a Qwen Code session. Different events support different matchers to filter trigger conditions. -
-Hook Lifecycle Diagram -
+| 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 | -The following table lists all available hook events in Qwen Code: +### Matcher Patterns -| Event Name | Description | Use Case | +<<<<<<< HEAD +`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` | +| 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)`) + +**Examples:** + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { + "type": "command", + "command": "echo 'bash check' >> /tmp/hooks.log" + } + ] + }, + { + "matcher": "Write.*", + "hooks": [ + { + "type": "command", + "command": "echo 'write check' >> /tmp/hooks.log" + } + ] + }, + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "echo 'all tools' >> /tmp/hooks.log" } + ] + } + ], + "SubagentStart": [ + { + "matcher": "^(Bash|Explorer)$", + "hooks": [ + { + "type": "command", + "command": "echo 'subagent check' >> /tmp/hooks.log" + } + ] + } + ] + } +} +``` + +======= +| Event Name | Description | Use Case | | -------------------- | ------------------------------------------- | ----------------------------------------------- | -| `PreToolUse` | Fired before tool execution | Permission checking, input validation, logging | -| `PostToolUse` | Fired after successful tool execution | Logging, output processing, monitoring | -| `PostToolUseFailure` | Fired when tool execution fails | Error handling, alerting, remediation | -| `Notification` | Fired when notifications are sent | Notification customization, logging | -| `UserPromptSubmit` | Fired when user submits a prompt | Input processing, validation, context injection | -| `SessionStart` | Fired when a new session starts | Initialization, context setup | -| `Stop` | Fired before Qwen concludes its response | Finalization, cleanup | -| `StopFailure` | Fired when turn ends due to API error | Error logging, alerting, rate limit handling | -| `SubagentStart` | Fired when a subagent starts | Subagent initialization | -| `SubagentStop` | Fired when a subagent stops | Subagent finalization | -| `PreCompact` | Fired before conversation compaction | Pre-compaction processing | -| `PostCompact` | Fired after conversation compaction | Summary archiving, usage statistics | -| `SessionEnd` | Fired when a session ends | Cleanup, reporting | -| `PermissionRequest` | Fired when permission dialogs are displayed | Permission automation, policy enforcement | +| `PreToolUse` | Fired before tool execution | Permission checking, input validation, logging | +| `PostToolUse` | Fired after successful tool execution | Logging, output processing, monitoring | +| `PostToolUseFailure` | Fired when tool execution fails | Error handling, alerting, remediation | +| `Notification` | Fired when notifications are sent | Notification customization, logging | +| `UserPromptSubmit` | Fired when user submits a prompt | Input processing, validation, context injection | +| `SessionStart` | Fired when a new session starts | Initialization, context setup | +| `Stop` | Fired before Qwen concludes its response | Finalization, cleanup | +| `StopFailure` | Fired when turn ends due to API error | Error logging, alerting, rate limit handling | +| `SubagentStart` | Fired when a subagent starts | Subagent initialization | +| `SubagentStop` | Fired when a subagent stops | Subagent finalization | +| `PreCompact` | Fired before conversation compaction | Pre-compaction processing | +| `PostCompact` | Fired after conversation compaction | Summary archiving, usage statistics | +| `SessionEnd` | Fired when a session ends | Cleanup, reporting | +| `PermissionRequest` | Fired when permission dialogs are displayed | Permission automation, policy enforcement | + +> > > > > > > main ## Input/Output Rules ### Hook Input Structure -All hooks receive standardized input in JSON format through stdin. Common fields included in every hook event: +All hooks receive standardized input in JSON format through stdin (command) or POST body (http). + +**Common Fields:** ```json { @@ -81,7 +258,39 @@ All hooks receive standardized input in JSON format through stdin. Common fields } ``` -Event-specific fields are added based on the hook type. Below are the event-specific fields for each hook event: +Event-specific fields are added based on the hook type. When running in a subagent, `agent_id` and `agent_type` are additionally included. + +### Hook Output Structure + +Hook output is returned via `stdout` (command) or HTTP response body (http) as JSON. + +**Exit Code Behavior (Command Hooks):** + +| Exit Code | Behavior | +| :-------- | :------------------------------------------------------------------------------------ | +| `0` | Success. Parse JSON in `stdout` to control behavior. | +| `2` | **Blocking error**. Ignores `stdout`, passes `stderr` as error feedback to the model. | +| Other | Non-blocking error. `stderr` only shown in debug mode, execution continues. | + +**Output Structure:** + +Hook output supports three categories of fields: + +1. **Common Fields**: `continue`, `stopReason`, `suppressOutput`, `systemMessage` +2. **Top-level Decision**: `decision`, `reason` (used by some events) +3. **Event-specific Control**: `hookSpecificOutput` (must include `hookEventName`) + +```json +{ + "continue": true, + "decision": "allow", + "reason": "Operation approved", + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": "Additional context information" + } +} +``` ### Individual Hook Event Details @@ -115,11 +324,8 @@ Event-specific fields are added based on the hook type. Below are the event-spec { "hookSpecificOutput": { "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "permissionDecisionReason": "My reason here", - "updatedInput": { - "field_to_modify": "new value" - }, + "permissionDecision": "deny", + "permissionDecisionReason": "Security policy blocks database writes", "additionalContext": "Current environment: production. Proceed with caution." } } @@ -578,12 +784,12 @@ Hooks are configured in Qwen Code settings, typically in `.qwen/settings.json` o "hooks": { "PreToolUse": [ { - "matcher": "^bash$", // Regex to match tool names - "sequential": false, // Whether to run hooks sequentially + "matcher": "^Bash$", + "sequential": false, "hooks": [ { "type": "command", - "command": "/path/to/script.sh", + "command": "/path/to/security-check.sh", "name": "security-check", "description": "Run security checks before tool execution", "timeout": 30000 @@ -606,62 +812,6 @@ Hooks are configured in Qwen Code settings, typically in `.qwen/settings.json` o } ``` -### Matcher Patterns - -Matchers allow filtering hooks based on context. Not all hook events support matchers: - -| Event Type | Events | Matcher Support | Matcher Target (Values) | -| ------------------- | ---------------------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------------- | -| Tool Events | `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest` | ✅ Yes (regex) | Tool name: `bash`, `read_file`, `write_file`, `edit`, `glob`, `grep_search`, etc. | -| Subagent Events | `SubagentStart`, `SubagentStop` | ✅ Yes (regex) | Agent type: `Bash`, `Explorer`, etc. | -| Session Events | `SessionStart` | ✅ Yes (regex) | Source: `startup`, `resume`, `clear`, `compact` | -| Session Events | `SessionEnd` | ✅ Yes (regex) | Reason: `clear`, `logout`, `prompt_input_exit`, `bypass_permissions_disabled`, `other` | -| Notification Events | `Notification` | ✅ Yes (exact) | Type: `permission_prompt`, `idle_prompt`, `auth_success` | -| Compact Events | `PreCompact` | ✅ Yes (exact) | Trigger: `manual`, `auto` | -| Prompt Events | `UserPromptSubmit` | ❌ No | N/A | -| Stop Events | `Stop` | ❌ No | N/A | - -**Matcher Syntax**: - -- Regex pattern matched against the target field -- Empty string `""` or `"*"` matches all events of that type -- Standard regex syntax supported (e.g., `^bash$`, `read.*`, `(bash|run_shell_command)`) - -**Examples**: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "^bash$", // Only match bash tool - "hooks": [...] - }, - { - "matcher": "read.*", // Match read_file, read_multiple_files, etc. - "hooks": [...] - }, - { - "matcher": "", // Match all tools (same as "*" or omitting matcher) - "hooks": [...] - } - ], - "SubagentStart": [ - { - "matcher": "^(Bash|Explorer)$", // Only match Bash and Explorer agents - "hooks": [...] - } - ], - "SessionStart": [ - { - "matcher": "^(startup|resume)$", // Only match startup and resume sources - "hooks": [...] - } - ] - } -} -``` - ## Hook Execution ### Parallel vs Sequential Execution @@ -670,38 +820,57 @@ Matchers allow filtering hooks based on context. Not all hook events support mat - Use `sequential: true` in hook definition to enforce order-dependent execution - Sequential hooks can modify input for subsequent hooks in the chain +### Async Hooks + +Only `command` type supports asynchronous execution. Setting `"async": true` runs the hook in the background without blocking the main flow. + +**Features:** + +- Cannot return decision control (operation has already occurred) +- Results are injected in the next conversation turn via `systemMessage` or `additionalContext` +- Suitable for auditing, logging, background testing, etc. + +**Example:** + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "WriteFile|Edit", + "hooks": [ + { + "type": "command", + "command": "$QWEN_PROJECT_DIR/.qwen/hooks/run-tests-async.sh", + "async": true, + "timeout": 300000 + } + ] + } + ] + } +} +``` + +```bash +#!/bin/bash +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') +if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.js ]]; then exit 0; fi +RESULT=$(npm test 2>&1) +if [ $? -eq 0 ]; then + echo "{\"systemMessage\": \"Tests passed after editing $FILE_PATH\"}" +else + echo "{\"systemMessage\": \"Tests failed: $RESULT\"}" +fi +``` + ### Security Model - Hooks run in the user's environment with user privileges - Project-level hooks require trusted folder status - Timeouts prevent hanging hooks (default: 60 seconds) -### Exit Codes - -Hook scripts communicate their result through exit codes: - -| Exit Code | Meaning | Behavior | -| --------- | ------------------ | ----------------------------------------------- | -| `0` | Success | stdout/stderr not shown | -| `2` | Blocking error | Show stderr to model and block tool call | -| Other | Non-blocking error | Show stderr to user only but continue tool call | - -**Examples**: - -```bash -#!/bin/bash - -# Success (exit 0 is default, can be omitted) -echo '{"decision": "allow"}' -exit 0 - -# Blocking error - prevents operation -echo "Dangerous operation blocked by security policy" >&2 -exit 2 -``` - -> **Note**: If no exit code is specified, the script defaults to `0` (success). - ## Best Practices ### Example 1: Security Validation Hook @@ -723,24 +892,20 @@ TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input') # Check for potentially dangerous operations if echo "$TOOL_INPUT" | grep -qiE "(rm.*-rf|mv.*\/|chmod.*777)"; then echo '{ - "decision": "deny", - "reason": "Potentially dangerous operation detected", "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", - "permissionDecisionReason": "Dangerous command blocked by security policy" + "permissionDecisionReason": "Security policy blocks dangerous command" } }' exit 2 # Blocking error fi -# Allow the operation with a log +# Log the operation echo "INFO: Tool $TOOL_NAME executed safely at $(date)" >> /var/log/qwen-security.log # Allow with additional context echo '{ - "decision": "allow", - "reason": "Operation approved by security checker", "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", @@ -773,7 +938,36 @@ Configure in `.qwen/settings.json`: } ``` -### Example 2: User Prompt Validation Hook +### Example 2: HTTP Audit Hook + +A PostToolUse HTTP hook that sends all tool execution records to a remote audit service: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "http", + "url": "https://audit.example.com/api/tool-execution", + "headers": { + "Authorization": "Bearer ${AUDIT_API_TOKEN}", + "Content-Type": "application/json" + }, + "allowedEnvVars": ["AUDIT_API_TOKEN"], + "timeout": 10, + "name": "audit-logger" + } + ] + } + ] + } +} +``` + +### Example 3: User Prompt Validation Hook A UserPromptSubmit hook that validates user prompts for sensitive information and provides context for long prompts: @@ -831,3 +1025,5 @@ exit(0) - Verify hook script permissions and executability - Ensure proper JSON formatting in hook outputs - Use specific matcher patterns to avoid unintended hook execution +- Use `--debug` mode to see detailed hook matching and execution information +- Temporarily disable all hooks: add `"disableAllHooks": true` in settings diff --git a/integration-tests/hook-integration/hooks-advanced.test.ts b/integration-tests/hook-integration/hooks-advanced.test.ts new file mode 100644 index 000000000..da73786e8 --- /dev/null +++ b/integration-tests/hook-integration/hooks-advanced.test.ts @@ -0,0 +1,1139 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + beforeAll, + afterAll, +} from 'vitest'; +import { TestRig } from '../test-helper.js'; +import { MockHttpServer, HttpHookResponses } from './mockHttpServer.js'; + +/** + * Advanced Hooks System Integration Tests + * + * Tests for HTTP Hooks, Async Hooks, and Function Hooks + * covering various events and scenarios + */ + +describe('HTTP Hooks Integration', () => { + let rig: TestRig; + let mockServer: MockHttpServer; + let serverUrl: string; + + beforeAll(async () => { + mockServer = new MockHttpServer(); + await mockServer.start(); + serverUrl = mockServer.getUrl(); + console.log(`Mock HTTP Server started at: ${serverUrl}`); + }); + + afterAll(async () => { + await mockServer.stop(); + }); + + beforeEach(() => { + rig = new TestRig(); + mockServer.clearRequestLogs(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + // ========================================================================== + // HTTP Hook - PreToolUse Events + // ========================================================================== + describe('PreToolUse HTTP Hooks', () => { + describe('Allow Decision', () => { + it('should allow tool execution when HTTP hook returns allow', async () => { + mockServer.setResponse( + '/pretooluse-allow', + HttpHookResponses.preToolUseAllow, + ); + + await rig.setup('http-pretooluse-allow', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'http', + url: `${serverUrl}/pretooluse-allow`, + name: 'http-allow-hook', + timeout: 10, + }, + ], + }, + ], + }, + }, + }); + + await rig.run('Create a file test.txt with content "hello"'); + + const foundToolCall = await rig.waitForToolCall('write_file'); + expect(foundToolCall).toBeTruthy(); + + const fileContent = rig.readFile('test.txt'); + expect(fileContent).toContain('hello'); + + const requestLogs = mockServer.getRequestLogs(); + if (requestLogs.length > 0) { + expect(requestLogs[0].url).toBe('/pretooluse-allow'); + } + }); + + it('should allow multiple tools with wildcard matcher', async () => { + mockServer.setResponse( + '/pretooluse-wildcard', + HttpHookResponses.preToolUseAllow, + ); + + await rig.setup('http-pretooluse-wildcard', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'http', + url: `${serverUrl}/pretooluse-wildcard`, + name: 'http-wildcard-hook', + timeout: 10, + }, + ], + }, + ], + }, + }, + }); + + await rig.run('What is 1+1?'); + + const requestLogs = mockServer.getRequestLogs(); + if (requestLogs.length > 0) { + expect(requestLogs[0].url).toBe('/pretooluse-wildcard'); + } + }); + }); + + describe('Additional Context', () => { + it('should include additional context from HTTP hook response', async () => { + mockServer.setResponse( + '/pretooluse-context', + HttpHookResponses.withContext('HTTP hook additional context'), + ); + + await rig.setup('http-pretooluse-context', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'http', + url: `${serverUrl}/pretooluse-context`, + name: 'http-context-hook', + timeout: 10, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run('Create a file context.txt with "test"'); + expect(result).toBeDefined(); + + const requestLogs = mockServer.getRequestLogs(); + if (requestLogs.length > 0) { + expect(requestLogs[0].url).toBe('/pretooluse-context'); + } + }); + }); + + describe('Timeout Handling', () => { + it('should continue execution when HTTP hook times out (non-blocking)', async () => { + mockServer.setResponse('/pretooluse-slow', { continue: true }); + + await rig.setup('http-pretooluse-timeout', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'http', + url: `${serverUrl}/pretooluse-slow`, + name: 'http-slow-hook', + timeout: 1, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run('Create a file timeout.txt with "test"'); + expect(result).toBeDefined(); + }); + }); + + describe('Error Handling', () => { + it('should continue execution when HTTP hook returns non-2xx status', async () => { + await rig.setup('http-pretooluse-error', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'http', + url: `${serverUrl}/nonexistent-endpoint`, + name: 'http-error-hook', + timeout: 5, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run('Create a file error.txt with "test"'); + expect(result).toBeDefined(); + }); + }); + + describe('URL Validation', () => { + it('should reject HTTP hook with blocked private IP', async () => { + await rig.setup('http-blocked-private-ip', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'http', + url: 'http://10.0.0.1:8080/hook', + name: 'http-private-ip-hook', + timeout: 5, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run('Create a file blocked.txt with "test"'); + expect(result).toBeDefined(); + }); + + it('should allow HTTP hook with loopback address (127.0.0.1)', async () => { + mockServer.setResponse('/loopback', HttpHookResponses.preToolUseAllow); + + await rig.setup('http-allow-loopback', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'http', + url: `${serverUrl}/loopback`, + name: 'http-loopback-hook', + timeout: 10, + }, + ], + }, + ], + }, + }, + }); + + await rig.run('Create a file loopback.txt with "test"'); + + const requestLogs = mockServer.getRequestLogs(); + if (requestLogs.length > 0) { + expect(requestLogs[0].url).toBe('/loopback'); + } + }); + }); + }); + + // ========================================================================== + // HTTP Hook - UserPromptSubmit Events + // ========================================================================== + describe('UserPromptSubmit HTTP Hooks', () => { + it('should process prompt through HTTP hook and allow', async () => { + mockServer.setResponse('/userprompt-allow', HttpHookResponses.allow); + + await rig.setup('http-userprompt-allow', { + settings: { + disableAllHooks: false, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'http', + url: `${serverUrl}/userprompt-allow`, + name: 'http-ups-allow', + timeout: 10, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + + const requestLogs = mockServer.getRequestLogs(); + if (requestLogs.length > 0) { + expect(requestLogs[0].body.hook_event_name).toBe('UserPromptSubmit'); + } + }); + + it('should add additional context from HTTP hook to prompt', async () => { + mockServer.setResponse( + '/userprompt-context', + HttpHookResponses.userPromptSubmitContext( + 'Extra context from HTTP hook', + ), + ); + + await rig.setup('http-userprompt-context', { + settings: { + disableAllHooks: false, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'http', + url: `${serverUrl}/userprompt-context`, + name: 'http-ups-context', + timeout: 10, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run('What is 2+2?'); + expect(result).toBeDefined(); + + const requestLogs = mockServer.getRequestLogs(); + if (requestLogs.length > 0) { + expect(requestLogs[0].url).toBe('/userprompt-context'); + } + }); + }); + + // ========================================================================== + // HTTP Hook - PostToolUse Events + // ========================================================================== + describe('PostToolUse HTTP Hooks', () => { + it('should call HTTP hook after successful tool execution', async () => { + mockServer.setResponse( + '/posttooluse', + HttpHookResponses.postToolUseContext( + 'Post-execution context from HTTP hook', + ), + ); + + await rig.setup('http-posttooluse', { + settings: { + disableAllHooks: false, + hooks: { + PostToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'http', + url: `${serverUrl}/posttooluse`, + name: 'http-post-hook', + timeout: 10, + }, + ], + }, + ], + }, + }, + }); + + await rig.run('Create a file post.txt with "test"'); + + const foundToolCall = await rig.waitForToolCall('write_file'); + expect(foundToolCall).toBeTruthy(); + + const requestLogs = mockServer.getRequestLogs(); + if (requestLogs.length > 0) { + expect(requestLogs[0].body.hook_event_name).toBe('PostToolUse'); + } + }); + }); + + // ========================================================================== + // HTTP Hook - SessionStart Events + // ========================================================================== + describe('SessionStart HTTP Hooks', () => { + it('should call HTTP hook on session start', async () => { + mockServer.setResponse('/sessionstart', { + continue: true, + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: 'Session initialization context', + }, + }); + + await rig.setup('http-sessionstart', { + settings: { + disableAllHooks: false, + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'http', + url: `${serverUrl}/sessionstart`, + name: 'http-session-start', + timeout: 10, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + + const requestLogs = mockServer.getRequestLogs(); + if (requestLogs.length > 0) { + expect(requestLogs[0].body.hook_event_name).toBe('SessionStart'); + } + }); + }); + + // ========================================================================== + // HTTP Hook - Multiple Hooks + // ========================================================================== + describe('Multiple HTTP Hooks', () => { + it('should execute multiple HTTP hooks in parallel', async () => { + mockServer.setResponse('/hook1', HttpHookResponses.preToolUseAllow); + mockServer.setResponse('/hook2', HttpHookResponses.preToolUseAllow); + + await rig.setup('http-multiple-parallel', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'http', + url: `${serverUrl}/hook1`, + name: 'http-hook-1', + timeout: 10, + }, + { + type: 'http', + url: `${serverUrl}/hook2`, + name: 'http-hook-2', + timeout: 10, + }, + ], + }, + ], + }, + }, + }); + + await rig.run('Create a file multi.txt with "test"'); + + const requestLogs = mockServer.getRequestLogs(); + expect(requestLogs.length).toBeGreaterThanOrEqual(0); + }); + + it('should execute HTTP hooks with command hooks together', async () => { + mockServer.setResponse('/mixed-http', HttpHookResponses.preToolUseAllow); + + await rig.setup('http-mixed-hooks', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'http', + url: `${serverUrl}/mixed-http`, + name: 'mixed-http-hook', + timeout: 10, + }, + { + type: 'command', + command: 'echo \'{"decision": "allow"}\'', + name: 'mixed-command-hook', + timeout: 5, + }, + ], + }, + ], + }, + }, + }); + + await rig.run('Create a file mixed.txt with "test"'); + + const requestLogs = mockServer.getRequestLogs(); + if (requestLogs.length > 0) { + expect(requestLogs[0].url).toBe('/mixed-http'); + } + }); + }); + + // ========================================================================== + // HTTP Hook - Once Flag + // ========================================================================== + describe('HTTP Hook Once Flag', () => { + it('should only execute once when once flag is set', async () => { + mockServer.setResponse('/once-hook', HttpHookResponses.preToolUseAllow); + + await rig.setup('http-once-flag', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'http', + url: `${serverUrl}/once-hook`, + name: 'once-http-hook', + timeout: 10, + once: true, + }, + ], + }, + ], + }, + }, + }); + + await rig.run('Create file1.txt with "a" and file2.txt with "b"'); + + const requestLogs = mockServer.getRequestLogs(); + expect(requestLogs.length).toBeLessThanOrEqual(1); + }); + }); +}); + +// ========================================================================== +// Async Hooks Integration Tests +// ========================================================================== +describe('Async Hooks Integration', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + // ========================================================================== + // Async Command Hooks - PreToolUse Events + // ========================================================================== + describe('Async PreToolUse Hooks', () => { + it('should execute async hook in background without blocking tool execution', async () => { + // Async hook runs in background, tool execution continues immediately + const asyncHookScript = ` + sleep 2 + echo '{"async": true, "hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": "Async hook completed"}}' >> async_output.txt + `; + + await rig.setup('async-pretooluse-background', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: asyncHookScript, + name: 'async-bg-hook', + timeout: 30, + async: true, + }, + ], + }, + ], + }, + }, + }); + + // Tool should execute immediately without waiting for async hook + await rig.run('Create a file async_test.txt with "hello"'); + + const foundToolCall = await rig.waitForToolCall('write_file'); + expect(foundToolCall).toBeTruthy(); + + const fileContent = rig.readFile('async_test.txt'); + expect(fileContent).toContain('hello'); + }); + + it('should run multiple async hooks concurrently without blocking', async () => { + const asyncHook1 = `sleep 1 && echo 'hook1_done' >> async_multi.txt`; + const asyncHook2 = `sleep 1 && echo 'hook2_done' >> async_multi.txt`; + + await rig.setup('async-pretooluse-concurrent', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: asyncHook1, + name: 'async-hook-1', + timeout: 30, + async: true, + }, + { + type: 'command', + command: asyncHook2, + name: 'async-hook-2', + timeout: 30, + async: true, + }, + ], + }, + ], + }, + }, + }); + + await rig.run('Create a file concurrent.txt with "test"'); + + // Tool should execute immediately + const foundToolCall = await rig.waitForToolCall('write_file'); + expect(foundToolCall).toBeTruthy(); + }); + + it('should allow sync hook to run alongside async hook', async () => { + const asyncHook = `sleep 2 && echo 'async_complete' >> async_sync_mix.txt`; + const syncHook = `echo '{"decision": "allow"}'`; + + await rig.setup('async-with-sync', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: asyncHook, + name: 'async-mixed-hook', + timeout: 30, + async: true, + }, + { + type: 'command', + command: syncHook, + name: 'sync-mixed-hook', + timeout: 5, + }, + ], + }, + ], + }, + }, + }); + + await rig.run('Create a file mixed_async_sync.txt with "test"'); + + // Sync hook should complete, async hook runs in background + const foundToolCall = await rig.waitForToolCall('write_file'); + expect(foundToolCall).toBeTruthy(); + }); + }); + + // ========================================================================== + // Async Command Hooks - PostToolUse Events + // ========================================================================== + describe('Async PostToolUse Hooks', () => { + it('should execute async hook after tool completion without blocking', async () => { + const asyncPostHook = ` + sleep 1 + echo 'post_async_done' >> post_async_log.txt + `; + + await rig.setup('async-posttooluse', { + settings: { + disableAllHooks: false, + hooks: { + PostToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: asyncPostHook, + name: 'async-post-hook', + timeout: 30, + async: true, + }, + ], + }, + ], + }, + }, + }); + + await rig.run('Create a file post_async.txt with "content"'); + + const foundToolCall = await rig.waitForToolCall('write_file'); + expect(foundToolCall).toBeTruthy(); + }); + + it('should run async audit logging after tool execution', async () => { + const auditHook = ` + echo '{"tool_name": "'$TOOL_NAME'", "timestamp": "'$(date -Iseconds)'"}' >> audit.log + `; + + await rig.setup('async-posttooluse-audit', { + settings: { + disableAllHooks: false, + hooks: { + PostToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: auditHook, + name: 'async-audit-hook', + timeout: 30, + async: true, + }, + ], + }, + ], + }, + }, + }); + + await rig.run('Create a file audited.txt with "test"'); + + const foundToolCall = await rig.waitForToolCall('write_file'); + expect(foundToolCall).toBeTruthy(); + }); + }); + + // ========================================================================== + // Async Command Hooks - SessionEnd Events + // ========================================================================== + describe('Async SessionEnd Hooks', () => { + it('should execute async cleanup hook on session end', async () => { + const cleanupHook = `echo 'session_ended' >> cleanup.log`; + + await rig.setup('async-sessionend-cleanup', { + settings: { + disableAllHooks: false, + hooks: { + SessionEnd: [ + { + hooks: [ + { + type: 'command', + command: cleanupHook, + name: 'async-cleanup-hook', + timeout: 5, + async: true, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run('Say goodbye'); + expect(result).toBeDefined(); + }); + }); + + // ========================================================================== + // Async Command Hooks - Timeout Handling + // ========================================================================== + describe('Async Hook Timeout', () => { + it('should handle async hook timeout gracefully without blocking', async () => { + const longRunningHook = `sleep 60 && echo 'finally_done' >> timeout_test.txt`; + + await rig.setup('async-hook-timeout', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: longRunningHook, + name: 'async-long-hook', + timeout: 2, // 2 second timeout - hook won't finish + async: true, + }, + ], + }, + ], + }, + }, + }); + + // Execution should not be blocked by timeout + await rig.run('Create a file timeout_async.txt with "test"'); + + const foundToolCall = await rig.waitForToolCall('write_file'); + expect(foundToolCall).toBeTruthy(); + }); + }); + + // ========================================================================== + // Async Command Hooks - Error Handling + // ========================================================================== + describe('Async Hook Error Handling', () => { + it('should continue execution when async hook fails', async () => { + const failingAsyncHook = `exit 1 && echo 'should_not_see_this' >> async_fail.txt`; + + await rig.setup('async-hook-failure', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: failingAsyncHook, + name: 'async-failing-hook', + timeout: 5, + async: true, + }, + ], + }, + ], + }, + }, + }); + + // Async hook failure should not block execution + const result = await rig.run( + 'Create a file async_fail_test.txt with "test"', + ); + expect(result).toBeDefined(); + }); + + it('should continue when async hook command does not exist', async () => { + await rig.setup('async-hook-missing-command', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: '/nonexistent/async/command', + name: 'async-missing-hook', + timeout: 5, + async: true, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run( + 'Create a file async_missing.txt with "test"', + ); + expect(result).toBeDefined(); + }); + }); + + // ========================================================================== + // Async Command Hooks - Concurrency Limits + // ========================================================================== + describe('Async Hook Concurrency', () => { + it('should handle multiple async hooks within concurrency limit', async () => { + const hooks = Array(5) + .fill(null) + .map((_, i) => ({ + type: 'command', + command: `sleep 1 && echo 'hook${i}_done' >> concurrent_limit.txt`, + name: `async-concurrent-hook-${i}`, + timeout: 30, + async: true, + })); + + await rig.setup('async-concurrency-limit', { + settings: { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: hooks, + }, + ], + }, + }, + }); + + await rig.run('Say concurrency test'); + + // All hooks should be registered (within default limit of 10) + expect(true).toBeTruthy(); + }); + }); +}); + +// ========================================================================== +// HTTP Hook - Stop Events +// ========================================================================== +describe('Stop HTTP Hooks Integration', () => { + let rig: TestRig; + let mockServer: MockHttpServer; + let serverUrl: string; + + beforeAll(async () => { + mockServer = new MockHttpServer(); + await mockServer.start(); + serverUrl = mockServer.getUrl(); + }); + + afterAll(async () => { + await mockServer.stop(); + }); + + beforeEach(() => { + rig = new TestRig(); + mockServer.clearRequestLogs(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + it('should call HTTP hook when stop event is triggered', async () => { + mockServer.setResponse( + '/stop', + HttpHookResponses.stopWithReason('Stop hook feedback from HTTP'), + ); + + await rig.setup('http-stop', { + settings: { + disableAllHooks: false, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'http', + url: `${serverUrl}/stop`, + name: 'http-stop-hook', + timeout: 5, + }, + ], + }, + ], + }, + }, + }); + + // Note: Stop hook requires explicit /stop command, which may not be triggered + // in --prompt mode (rig.run). This test verifies the setup is valid. + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + + // Stop hook may not be triggered in --prompt mode as it requires /stop command + // This is expected behavior - we just verify the test doesn't crash + }); +}); + +// ========================================================================== +// HTTP Hook - Notification Events +// ========================================================================== +describe('Notification HTTP Hooks Integration', () => { + let rig: TestRig; + let mockServer: MockHttpServer; + let serverUrl: string; + + beforeAll(async () => { + mockServer = new MockHttpServer(); + await mockServer.start(); + serverUrl = mockServer.getUrl(); + }); + + afterAll(async () => { + await mockServer.stop(); + }); + + beforeEach(() => { + rig = new TestRig(); + mockServer.clearRequestLogs(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + it('should call HTTP hook when notification is sent', async () => { + mockServer.setResponse('/notification', { + continue: true, + hookSpecificOutput: { + hookEventName: 'Notification', + additionalContext: 'Notification processed by HTTP hook', + }, + }); + + await rig.setup('http-notification', { + settings: { + disableAllHooks: false, + hooks: { + Notification: [ + { + hooks: [ + { + type: 'http', + url: `${serverUrl}/notification`, + name: 'http-notification-hook', + timeout: 5, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run('Say notification test'); + expect(result).toBeDefined(); + }); +}); + +// ========================================================================== +// HTTP Hook - PreCompact Events +// ========================================================================== +describe('PreCompact HTTP Hooks Integration', () => { + let rig: TestRig; + let mockServer: MockHttpServer; + let serverUrl: string; + + beforeAll(async () => { + mockServer = new MockHttpServer(); + await mockServer.start(); + serverUrl = mockServer.getUrl(); + }); + + afterAll(async () => { + await mockServer.stop(); + }); + + beforeEach(() => { + rig = new TestRig(); + mockServer.clearRequestLogs(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + it('should call HTTP hook before conversation compaction', async () => { + mockServer.setResponse('/precompact', { + continue: true, + hookSpecificOutput: { + hookEventName: 'PreCompact', + additionalContext: 'Pre-compact context from HTTP hook', + }, + }); + + await rig.setup('http-precompact', { + settings: { + disableAllHooks: false, + hooks: { + PreCompact: [ + { + hooks: [ + { + type: 'http', + url: `${serverUrl}/precompact`, + name: 'http-precompact-hook', + timeout: 5, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run('Say precompact test'); + expect(result).toBeDefined(); + }); +}); diff --git a/integration-tests/hook-integration/mockHttpServer.ts b/integration-tests/hook-integration/mockHttpServer.ts new file mode 100644 index 000000000..91624b164 --- /dev/null +++ b/integration-tests/hook-integration/mockHttpServer.ts @@ -0,0 +1,254 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createServer, + type Server, + type IncomingMessage, + type ServerResponse, +} from 'http'; + +/** + * Hook output type for HTTP hook responses + */ +export interface HookOutput { + continue?: boolean; + stopReason?: string; + suppressOutput?: boolean; + systemMessage?: string; + decision?: 'ask' | 'block' | 'deny' | 'approve' | 'allow'; + reason?: string; + hookSpecificOutput?: Record; +} + +/** + * Mock HTTP Server for testing HTTP hooks + * Provides endpoints that simulate various hook response scenarios + */ +export class MockHttpServer { + private server: Server | null = null; + private port: number = 0; + private readonly responses: Map< + string, + HookOutput | ((input: Record) => HookOutput) + > = new Map(); + private readonly requestLogs: Array<{ + url: string; + body: Record; + timestamp: number; + }> = []; + + /** + * Start the mock server on a random available port + */ + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = createServer((req, res) => { + this.handleRequest(req, res); + }); + + this.server.listen(0, () => { + const address = this.server!.address(); + if (address && typeof address === 'object') { + this.port = address.port; + resolve(this.port); + } else { + reject(new Error('Failed to get server port')); + } + }); + + this.server.on('error', reject); + }); + } + + /** + * Stop the mock server + */ + async stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => { + this.server = null; + resolve(); + }); + } else { + resolve(); + } + }); + } + + /** + * Get the server's base URL + */ + getUrl(): string { + return `http://127.0.0.1:${this.port}`; + } + + /** + * Set response for a specific path + */ + setResponse( + path: string, + response: HookOutput | ((input: Record) => HookOutput), + ): void { + this.responses.set(path, response); + } + + /** + * Get all received request logs + */ + getRequestLogs(): Array<{ + url: string; + body: Record; + timestamp: number; + }> { + return [...this.requestLogs]; + } + + /** + * Clear request logs + */ + clearRequestLogs(): void { + this.requestLogs.length = 0; + } + + /** + * Handle incoming HTTP request + */ + private handleRequest(req: IncomingMessage, res: ServerResponse): void { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + + req.on('end', () => { + const parsedBody = JSON.parse(body || '{}'); + + // Log the request + this.requestLogs.push({ + url: req.url || '/', + body: parsedBody, + timestamp: Date.now(), + }); + + // Find matching response + const response = this.responses.get(req.url || '/'); + + if (response) { + const output = + typeof response === 'function' ? response(parsedBody) : response; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(output)); + } else { + // Default response: allow with continue + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ continue: true })); + } + }); + + req.on('error', (err) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + }); + } +} + +/** + * Pre-defined response scenarios for HTTP hook testing + */ +export const HttpHookResponses = { + /** Allow execution */ + allow: { decision: 'allow', continue: true } as HookOutput, + + /** Block execution */ + block: { + decision: 'block', + reason: 'Blocked by HTTP hook', + continue: false, + } as HookOutput, + + /** Ask for permission */ + ask: { decision: 'ask', reason: 'User confirmation required' } as HookOutput, + + /** Deny execution */ + deny: { decision: 'deny', reason: 'Denied by HTTP hook' } as HookOutput, + + /** Return additional context */ + withContext: (context: string): HookOutput => ({ + continue: true, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + additionalContext: context, + }, + }), + + /** Return system message */ + withSystemMessage: (message: string): HookOutput => ({ + continue: true, + systemMessage: message, + }), + + /** PreToolUse allow with permission decision */ + preToolUseAllow: { + continue: true, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + permissionDecisionReason: 'Tool execution approved by HTTP hook', + }, + } as HookOutput, + + /** PreToolUse deny with permission decision */ + preToolUseDeny: { + continue: false, + decision: 'deny', + reason: 'Tool execution denied by HTTP hook', + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'Security policy violation', + }, + } as HookOutput, + + /** PreToolUse ask for confirmation */ + preToolUseAsk: { + continue: true, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'ask', + permissionDecisionReason: 'Requires user confirmation', + }, + } as HookOutput, + + /** UserPromptSubmit with additional context */ + userPromptSubmitContext: (context: string): HookOutput => ({ + continue: true, + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: context, + }, + }), + + /** PostToolUse with additional context */ + postToolUseContext: (context: string): HookOutput => ({ + continue: true, + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: context, + }, + }), + + /** Stop hook with stop reason */ + stopWithReason: (reason: string): HookOutput => ({ + continue: true, + stopReason: reason, + hookSpecificOutput: { + hookEventName: 'Stop', + additionalContext: `Stop reason: ${reason}`, + }, + }), +}; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index a1e81eae4..ee79e0d59 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -452,7 +452,17 @@ class QwenAgent implements Agent { continue: false, }; - const config = await loadCliConfig(settings, argvForSession, cwd, []); + const config = await loadCliConfig( + settings, + argvForSession, + cwd, + [], + // Pass separated hooks for proper source attribution + { + userHooks: this.settings.getUserHooks(), + projectHooks: this.settings.getProjectHooks(), + }, + ); await config.initialize(); return config; } diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts index 6d5ce8751..3a5a3ab4d 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -117,6 +117,11 @@ export async function handleQwenAuth( minimalArgv, process.cwd(), [], // No extensions for auth command + // Pass separated hooks for proper source attribution + { + userHooks: settings.getUserHooks(), + projectHooks: settings.getProjectHooks(), + }, ); if (command === 'qwen-oauth') { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 8f968a1e2..fbb6eb7de 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -701,6 +701,14 @@ export async function loadCliConfig( argv: CliArgs, cwd: string = process.cwd(), overrideExtensions?: string[], + /** + * Optional separated hooks for proper source attribution. + * If provided, these override settings.hooks for hook loading. + */ + hooksConfig?: { + userHooks?: Record; + projectHooks?: Record; + }, ): Promise { const debugMode = isDebugMode(argv); @@ -1099,6 +1107,7 @@ export async function loadCliConfig( generationConfigSources: resolvedCliConfig.sources, generationConfig: resolvedCliConfig.generationConfig, warnings: resolvedCliConfig.warnings, + allowedHttpHookUrls: settings.security?.allowedHttpHookUrls ?? [], cliVersion: await getCliVersion(), webSearch: buildWebSearchConfig(argv, settings, selectedAuthType), ideMode, @@ -1119,7 +1128,10 @@ export async function loadCliConfig( output: { format: outputSettingsFormat, }, - hooks: settings.hooks, + // Use separated hooks if provided, otherwise fall back to merged hooks + userHooks: hooksConfig?.userHooks ?? settings.hooks, + projectHooks: hooksConfig?.projectHooks, + hooks: settings.hooks, // Keep for backward compatibility disableAllHooks: settings.disableAllHooks ?? false, channel: argv.channel, // Precedence: explicit CLI flag > settings file > default(true). diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 80ac496ab..38e86bc9c 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -437,6 +437,26 @@ export class LoadedSettings { this._merged = this.computeMergedSettings(); saveSettings(settingsFile, createSettingsUpdate(key, value)); } + + /** + * Get user-level hooks from user settings (not merged with workspace). + * These hooks should always be loaded regardless of folder trust. + */ + getUserHooks(): Record | undefined { + return this.user.settings.hooks; + } + + /** + * Get project-level hooks from workspace settings (not merged). + * Returns undefined if workspace is not trusted (hooks filtered out). + */ + getProjectHooks(): Record | undefined { + // Only return project hooks if workspace is trusted + if (!this.isTrusted) { + return undefined; + } + return this.workspace.settings.hooks; + } } /** diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fa44f29a4..0ab42b435 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -131,18 +131,36 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = { items: { type: 'object', description: - 'A hook configuration entry that defines a command to execute.', + 'A hook configuration entry that defines a hook to execute.', properties: { type: { type: 'string', - description: 'The type of hook.', - enum: ['command'], + description: + 'The type of hook. Note: "function" type is only available via SDK registration, not settings.json.', + enum: ['command', 'http'], required: true, }, command: { type: 'string', - description: 'The command to execute when the hook is triggered.', - required: true, + description: + 'The command to execute when the hook is triggered. Required for "command" type.', + }, + url: { + type: 'string', + description: + 'The URL to send the POST request to. Required for "http" type.', + }, + headers: { + type: 'object', + description: + 'HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).', + additionalProperties: { type: 'string' }, + }, + allowedEnvVars: { + type: 'array', + description: + 'List of environment variables allowed for interpolation in headers and URL.', + items: { type: 'string' }, }, name: { type: 'string', @@ -154,7 +172,7 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = { }, timeout: { type: 'number', - description: 'Timeout in milliseconds for the hook execution.', + description: 'Timeout in seconds for the hook execution.', }, env: { type: 'object', @@ -162,6 +180,25 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = { 'Environment variables to set when executing the hook command.', additionalProperties: { type: 'string' }, }, + async: { + type: 'boolean', + description: + 'Whether to execute the hook asynchronously (non-blocking, for "command" type only).', + }, + once: { + type: 'boolean', + description: + 'Whether to execute the hook only once per session (for "http" type).', + }, + statusMessage: { + type: 'string', + description: 'A message to display while the hook is executing.', + }, + shell: { + type: 'string', + description: 'The shell to use for command execution.', + enum: ['bash', 'powershell'], + }, }, }, }, @@ -1338,6 +1375,20 @@ const SETTINGS_SCHEMA = { }, }, }, + allowedHttpHookUrls: { + type: 'array', + label: 'Allowed HTTP Hook URLs', + category: 'Security', + requiresRestart: false, + default: [] as string[], + description: + 'Whitelist of URL patterns for HTTP hooks. Supports * wildcard. If empty, all URLs are allowed (subject to SSRF protection).', + showInDialog: false, + items: { + type: 'string', + description: 'URL pattern (supports * wildcard)', + }, + }, }, }, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 90e99824d..6ebabdfa9 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -194,6 +194,8 @@ describe('gemini.tsx main function', () => { setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), migrationWarnings: [], + getUserHooks: () => undefined, + getProjectHooks: () => undefined, } as never); try { await main(); @@ -327,6 +329,8 @@ describe('gemini.tsx main function', () => { setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), migrationWarnings: [], + getUserHooks: () => undefined, + getProjectHooks: () => undefined, } as never); vi.mocked(parseArguments).mockResolvedValue({ @@ -465,6 +469,8 @@ describe('gemini.tsx main function kitty protocol', () => { setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), migrationWarnings: [], + getUserHooks: () => undefined, + getProjectHooks: () => undefined, } as never); vi.mocked(parseArguments).mockResolvedValue({ model: undefined, @@ -564,6 +570,8 @@ describe('startInteractiveUI', () => { hideWindowTitle: false, }, }, + getUserHooks: () => undefined, + getProjectHooks: () => undefined, } as LoadedSettings; const mockStartupWarnings = ['warning1']; const mockWorkspaceRoot = '/root'; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 1d9158ac6..72dc7db76 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -269,6 +269,11 @@ export async function main() { argv, undefined, [], + // Pass separated hooks for proper source attribution + { + userHooks: settings.getUserHooks(), + projectHooks: settings.getProjectHooks(), + }, ); if (!settings.merged.security?.auth?.useExternal) { @@ -369,6 +374,11 @@ export async function main() { argv, process.cwd(), argv.extensions, + // Pass separated hooks for proper source attribution + { + userHooks: settings.getUserHooks(), + projectHooks: settings.getProjectHooks(), + }, ); profileCheckpoint('after_load_cli_config'); diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index ca5333e0d..df4960259 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -650,6 +650,7 @@ export default { 'User Settings': 'Benutzereinstellungen', 'System Settings': 'Systemeinstellungen', Extensions: 'Erweiterungen', + 'Session (temporary)': 'Sitzung (temporär)', // Hooks - Status '✓ Enabled': '✓ Aktiviert', '✗ Disabled': '✗ Deaktiviert', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 960408d04..387fa2f73 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -435,6 +435,7 @@ export default { 'User Settings': 'ユーザー設定', 'System Settings': 'システム設定', Extensions: '拡張機能', + 'Session (temporary)': 'セッション(一時)', // Hooks - Status '✓ Enabled': '✓ 有効', '✗ Disabled': '✗ 無効', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 24ae5571f..5652dab44 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -657,6 +657,7 @@ export default { 'User Settings': 'Configurações do Usuário', 'System Settings': 'Configurações do Sistema', Extensions: 'Extensões', + 'Session (temporary)': 'Sessão (temporário)', // Hooks - Status '✓ Enabled': '✓ Ativado', '✗ Disabled': '✗ Desativado', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index b5e673cec..42094a72e 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -662,6 +662,7 @@ export default { 'User Settings': 'Пользовательские настройки', 'System Settings': 'Системные настройки', Extensions: 'Расширения', + 'Session (temporary)': 'Сессия (временно)', // Hooks - Status '✓ Enabled': '✓ Включен', '✗ Disabled': '✗ Отключен', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 476f5bfc5..f2e10e2ab 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -687,6 +687,7 @@ export default { 'User Settings': '用户设置', 'System Settings': '系统设置', Extensions: '扩展', + 'Session (temporary)': '会话(临时)', // Hooks - Status '✓ Enabled': '✓ 已启用', '✗ Disabled': '✗ 已禁用', diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index 2da70b0d0..750081582 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -69,20 +69,4 @@ describe('hooksCommand', () => { }); }); }); - - describe('non-interactive mode', () => { - it('should list hooks in non-interactive mode', async () => { - const nonInteractiveContext = createMockCommandContext({ - services: { - config: mockConfig, - }, - executionMode: 'non_interactive', - }); - - const result = await hooksCommand.action!(nonInteractiveContext, ''); - - // In non-interactive mode, it should return a message - expect(result).toHaveProperty('type', 'message'); - }); - }); }); diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 2a007dfeb..49902994d 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -12,7 +12,10 @@ import type { } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; -import type { HookRegistryEntry } from '@qwen-code/qwen-code-core'; +import type { + HookRegistryEntry, + SessionHookEntry, +} from '@qwen-code/qwen-code-core'; /** * Format hook source for display @@ -27,18 +30,13 @@ function formatHookSource(source: string): string { return t('System'); case 'extensions': return t('Extension'); + case 'session': + return t('Session (temporary)'); default: return source; } } -/** - * Format hook status for display - */ -function formatHookStatus(enabled: boolean): string { - return enabled ? t('✓ Enabled') : t('✗ Disabled'); -} - const listCommand: SlashCommand = { name: 'list', get description() { @@ -70,38 +68,105 @@ const listCommand: SlashCommand = { } const registry = hookSystem.getRegistry(); - const allHooks = registry.getAllHooks(); + const configHooks = registry.getAllHooks(); - if (allHooks.length === 0) { + // Get session hooks + const sessionId = config.getSessionId(); + const sessionHooksManager = hookSystem.getSessionHooksManager(); + const sessionHooks = sessionId + ? sessionHooksManager.getAllSessionHooks(sessionId) + : []; + + const totalHooks = configHooks.length + sessionHooks.length; + + if (totalHooks === 0) { return { type: 'message', messageType: 'info', content: t( - 'No hooks configured. Add hooks in your settings.json file.', + 'No hooks configured. Add hooks in your settings.json file or invoke a skill with hooks.', ), }; } // Group hooks by event - const hooksByEvent = new Map(); - for (const hook of allHooks) { + const hooksByEvent = new Map< + string, + Array<{ hook: HookRegistryEntry | SessionHookEntry; isSession: boolean }> + >(); + + // Add config hooks + for (const hook of configHooks) { const eventName = hook.eventName; if (!hooksByEvent.has(eventName)) { hooksByEvent.set(eventName, []); } - hooksByEvent.get(eventName)!.push(hook); + hooksByEvent.get(eventName)!.push({ hook, isSession: false }); } - let output = `**Configured Hooks (${allHooks.length} total)**\n\n`; + // Add session hooks + for (const hook of sessionHooks) { + const eventName = hook.eventName; + if (!hooksByEvent.has(eventName)) { + hooksByEvent.set(eventName, []); + } + hooksByEvent.get(eventName)!.push({ hook, isSession: true }); + } + + let output = `**Configured Hooks (${totalHooks} total)**\n\n`; for (const [eventName, hooks] of hooksByEvent) { output += `### ${eventName}\n`; - for (const hook of hooks) { - const name = hook.config.name || hook.config.command || 'unnamed'; - const source = formatHookSource(hook.source); - const status = formatHookStatus(hook.enabled); - const matcher = hook.matcher ? ` (matcher: ${hook.matcher})` : ''; - output += `- **${name}** [${source}] ${status}${matcher}\n`; + for (const { hook, isSession } of hooks) { + let name: string; + let source: string; + let matcher: string; + let config: { + type: string; + command?: string; + url?: string; + name?: string; + }; + + if (isSession) { + // Session hook + const sessionHook = hook as SessionHookEntry; + config = sessionHook.config as { + type: string; + command?: string; + url?: string; + name?: string; + }; + name = + config.name || + (config.type === 'command' ? config.command : undefined) || + (config.type === 'http' ? config.url : undefined) || + 'unnamed'; + source = formatHookSource('session'); + matcher = sessionHook.matcher + ? ` (matcher: ${sessionHook.matcher})` + : ''; + } else { + // Config hook + const configHook = hook as HookRegistryEntry; + config = configHook.config as { + type: string; + command?: string; + url?: string; + name?: string; + }; + name = + config.name || + (config.type === 'command' ? config.command : undefined) || + (config.type === 'http' ? config.url : undefined) || + 'unnamed'; + source = formatHookSource(configHook.source); + matcher = configHook.matcher + ? ` (matcher: ${configHook.matcher})` + : ''; + } + + output += `- **${name}** [${source}]${matcher}\n`; } output += '\n'; } diff --git a/packages/cli/src/ui/components/hooks/HookDetailStep.tsx b/packages/cli/src/ui/components/hooks/HookDetailStep.tsx index 69c5d24e3..68d575186 100644 --- a/packages/cli/src/ui/components/hooks/HookDetailStep.tsx +++ b/packages/cli/src/ui/components/hooks/HookDetailStep.tsx @@ -8,7 +8,7 @@ import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import type { HookEventDisplayInfo } from './types.js'; -import { HooksConfigSource } from '@qwen-code/qwen-code-core'; +import { HooksConfigSource, HookType } from '@qwen-code/qwen-code-core'; import { getTranslatedSourceDisplayMap } from './constants.js'; import { t } from '../../../i18n/index.js'; @@ -86,13 +86,33 @@ export function HookDetailStep({ {hook.configs.map((config, index) => { const isSelected = index === selectedIndex; const sourceDisplay = getConfigSourceDisplay(config); - const command = - config.config.type === 'command' ? config.config.command : ''; + + // Get display text based on hook type + let hookDisplay = ''; const hookType = config.config.type; + if (hookType === HookType.Command) { + // For command hook, show command (truncate if too long) + hookDisplay = config.config.command || ''; + } else if (hookType === HookType.Http) { + // For http hook, show name or url + hookDisplay = config.config.name || config.config.url || ''; + } else if (hookType === HookType.Function) { + // For function hook, show name or id + hookDisplay = + config.config.name || config.config.id || 'function-hook'; + } + + // Check if this is an async hook (only command hooks support async) + const isAsync = + hookType === HookType.Command && config.config.async === true; + const typeDisplay = isAsync + ? `${hookType} async` + : String(hookType); + return ( - {/* Left column: selector + command */} + {/* Left column: selector + display */} - {`${index + 1}. [${hookType}] ${command}`} + {`${index + 1}. [${typeDisplay}] ${hookDisplay}`} {/* Spacer between columns */} diff --git a/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx b/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx index 722cad5f9..53330ffd2 100644 --- a/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx +++ b/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx @@ -59,6 +59,12 @@ vi.mock('../../contexts/ConfigContext.js', async (importOriginal) => { useConfig: vi.fn(() => ({ getExtensions: vi.fn(() => []), getDisableAllHooks: vi.fn(() => false), + getHookSystem: vi.fn(() => ({ + getSessionHooksManager: vi.fn(() => ({ + getAllSessionHooks: vi.fn(() => []), + })), + })), + getSessionId: vi.fn(() => 'test-session-id'), })), }; }); @@ -159,20 +165,6 @@ describe('HooksManagementDialog', () => { unmount(); }); - - it('should handle empty hooks list gracefully', async () => { - const { lastFrame, unmount } = renderWithProviders( - , - ); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - const output = lastFrame(); - // Should show 0 hooks configured when no hooks are configured - expect(output).toContain('0 hooks configured'); - - unmount(); - }); }); describe('Keyboard navigation - HOOKS_LIST step', () => { diff --git a/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx b/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx index 837d116e9..392e91515 100644 --- a/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx +++ b/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx @@ -15,6 +15,7 @@ import { HooksConfigSource, type HookDefinition, type HookConfig, + type SessionHookEntry, createDebugLogger, HOOKS_CONFIG_FIELDS, } from '@qwen-code/qwen-code-core'; @@ -40,13 +41,21 @@ const debugLogger = createDebugLogger('HOOKS_DIALOG'); * Type guard to check if a value is a valid HookConfig */ function isValidHookConfig(config: unknown): config is HookConfig { - return ( - typeof config === 'object' && - config !== null && - 'type' in config && - 'command' in config && - typeof (config as HookConfig).command === 'string' - ); + if (typeof config !== 'object' || config === null || !('type' in config)) { + return false; + } + const obj = config as Record; + // Check based on type + if (obj['type'] === 'command') { + return 'command' in obj && typeof obj['command'] === 'string'; + } + if (obj['type'] === 'http') { + return 'url' in obj && typeof obj['url'] === 'string'; + } + if (obj['type'] === 'function') { + return 'callback' in obj && typeof obj['callback'] === 'function'; + } + return false; } /** @@ -299,6 +308,33 @@ export function HooksManagementDialog({ } } + // Get session hooks from SessionHooksManager + const hookSystem = config.getHookSystem(); + if (hookSystem) { + const sessionId = config.getSessionId(); + if (sessionId) { + const sessionHooksManager = hookSystem.getSessionHooksManager(); + const allSessionHooks = + sessionHooksManager.getAllSessionHooks(sessionId); + + // Filter hooks for this event + const eventSessionHooks = allSessionHooks.filter( + (hook: SessionHookEntry) => hook.eventName === eventName, + ); + + for (const sessionHook of eventSessionHooks) { + // Session hooks have matcher stored separately from config + hookInfo.configs.push({ + config: sessionHook.config as HookConfig, + source: HooksConfigSource.Session, + sourceDisplay: t('Session (temporary)'), + matcher: sessionHook.matcher, + enabled: true, + }); + } + } + } + result.push(hookInfo); } @@ -311,7 +347,9 @@ export function HooksManagementDialog({ setIsLoading(true); setLoadError(null); try { + debugLogger.debug('Fetching hooks data for dialog'); const hooksData = fetchHooksData(); + debugLogger.debug('Hooks data fetched:', hooksData.length, 'events'); if (!cancelled) { setHooks(hooksData); } diff --git a/packages/cli/src/ui/components/hooks/constants.ts b/packages/cli/src/ui/components/hooks/constants.ts index 2a5b1011f..b91178554 100644 --- a/packages/cli/src/ui/components/hooks/constants.ts +++ b/packages/cli/src/ui/components/hooks/constants.ts @@ -180,6 +180,7 @@ export function getTranslatedSourceDisplayMap(): Record< [HooksConfigSource.User]: t('User Settings'), [HooksConfigSource.System]: t('System Settings'), [HooksConfigSource.Extensions]: t('Extensions'), + [HooksConfigSource.Session]: t('Session (temporary)'), }; } diff --git a/packages/cli/src/ui/components/hooks/types.ts b/packages/cli/src/ui/components/hooks/types.ts index 4a8a3217b..a00ac0f24 100644 --- a/packages/cli/src/ui/components/hooks/types.ts +++ b/packages/cli/src/ui/components/hooks/types.ts @@ -37,6 +37,7 @@ export interface HookConfigDisplayInfo { source: HooksConfigSource; sourceDisplay: string; sourcePath?: string; + matcher?: string; enabled: boolean; } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 888c32ee0..ae59a29a4 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -103,6 +103,8 @@ import { PermissionMode, NotificationType, type PermissionSuggestion, + type HookEventName, + type HookDefinition, } from '../hooks/types.js'; import { fireNotificationHook } from '../core/toolHookTriggers.js'; @@ -447,10 +449,23 @@ export interface ConfigParameters { * to use disableAllHooks instead (note: inverted logic - enabled:true → disableAllHooks:false). */ disableAllHooks?: boolean; - /** Hooks configuration from settings */ + /** + * User-level hooks configuration (from user settings). + * These hooks are always loaded regardless of folder trust status. + */ + userHooks?: Record; + /** + * Project-level hooks configuration (from workspace settings). + * These hooks are only loaded in trusted folders. + * When undefined or the folder is untrusted, project hooks are skipped. + */ + projectHooks?: Record; + hooks?: Record; /** Warnings generated during configuration resolution */ warnings?: string[]; + /** Allowed HTTP hook URLs whitelist (from security.allowedHttpHookUrls) */ + allowedHttpHookUrls?: string[]; /** * Callback for persisting a permission rule to settings. * Injected by the CLI layer; core uses this to write allow/ask/deny rules @@ -609,6 +624,7 @@ export class Config { private readonly skipLoopDetection: boolean; private readonly skipStartupContext: boolean; private readonly warnings: string[]; + private readonly allowedHttpHookUrls: string[]; private readonly onPersistPermissionRuleCallback?: ( scope: 'project' | 'user', ruleType: 'allow' | 'ask' | 'deny', @@ -623,6 +639,11 @@ export class Config { private readonly channel: string | undefined; private readonly defaultFileEncoding: FileEncodingType | undefined; private readonly disableAllHooks: boolean; + /** User-level hooks (always loaded regardless of trust) */ + private readonly userHooks?: Record; + /** Project-level hooks (only loaded in trusted folders) */ + private readonly projectHooks?: Record; + /** @deprecated Legacy merged hooks field - use userHooks/projectHooks instead */ private readonly hooks?: Record; private hookSystem?: HookSystem; private messageBus?: MessageBus; @@ -732,6 +753,7 @@ export class Config { this.skipLoopDetection = params.skipLoopDetection ?? false; this.skipStartupContext = params.skipStartupContext ?? false; this.warnings = params.warnings ?? []; + this.allowedHttpHookUrls = params.allowedHttpHookUrls ?? []; this.onPersistPermissionRuleCallback = params.onPersistPermissionRule; // Web search @@ -798,6 +820,10 @@ export class Config { isWorkspaceTrusted: this.isTrustedFolder(), }); this.disableAllHooks = params.disableAllHooks ?? false; + // Store user and project hooks separately for proper source attribution + this.userHooks = params.userHooks; + this.projectHooks = params.projectHooks; + // Legacy: fall back to merged hooks if new fields are not provided this.hooks = params.hooks; } @@ -1919,20 +1945,28 @@ export class Config { /** * Get project-level hooks configuration. - * This is used by the HookRegistry to load project-specific hooks. + * Returns hooks from workspace settings, only in trusted folders. + * Used by HookRegistry to load project-specific hooks with proper source attribution. */ - getProjectHooks(): Record | undefined { - // This will be populated from settings by the CLI layer - // The core Config doesn't have direct access to settings - return undefined; + getProjectHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined { + // Only return project hooks if workspace is trusted + if (!this.isTrustedFolder()) { + return undefined; + } + // Prefer new projectHooks field, fall back to hooks for backward compatibility + const hooks = this.projectHooks ?? this.hooks; + return hooks as { [K in HookEventName]?: HookDefinition[] } | undefined; } /** - * Get all hooks configuration (merged from all sources). - * This is used by the HookRegistry to load hooks. + * Get user-level hooks configuration. + * Returns hooks from user settings, always available regardless of folder trust. + * Used by HookRegistry to load user-specific hooks with proper source attribution. */ - getHooks(): Record | undefined { - return this.hooks; + getUserHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined { + // Prefer new userHooks field, fall back to hooks for backward compatibility + const hooks = this.userHooks ?? this.hooks; + return hooks as { [K in HookEventName]?: HookDefinition[] } | undefined; } getExtensions(): Extension[] { @@ -2010,6 +2044,14 @@ export class Config { return this.folderTrust; } + /** + * Returns the whitelist of allowed HTTP hook URL patterns. + * If empty, all URLs are allowed (subject to SSRF protection). + */ + getAllowedHttpHookUrls(): string[] { + return this.allowedHttpHookUrls; + } + isTrustedFolder(): boolean { // isWorkspaceTrusted in cli/src/config/trustedFolder.js returns undefined // when the file based trust value is unavailable, since it is mainly used diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index 5a251ce26..1c07118e6 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -504,9 +504,10 @@ describe('convertClaudePluginPackage', () => { expect(result.config.hooks).toBeDefined(); expect(result.config.hooks!['PostToolUse']).toHaveLength(1); // Check that the variable was substituted - expect(result.config.hooks!['PostToolUse']![0].hooks![0].command).toBe( - `${pluginSourceDir}/scripts/post-install.sh`, - ); + expect( + (result.config.hooks!['PostToolUse']![0].hooks![0] as { command: string }) + .command, + ).toBe(`${pluginSourceDir}/scripts/post-install.sh`); // Clean up converted directory fs.rmSync(result.convertedDir, { recursive: true, force: true }); diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 7b43eddc7..67f43c94e 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -808,9 +808,13 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].hooks).toBeDefined(); expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1); - expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe( - 'echo "hello"', - ); + expect( + ( + extensions[0].hooks!['PreToolUse']![0].hooks![0] as { + command: string; + } + ).command, + ).toBe('echo "hello"'); }); it('should load hooks from hooks/hooks.json when not in main config', async () => { @@ -861,9 +865,13 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].hooks).toBeDefined(); expect(extensions[0].hooks!['PostToolUse']).toHaveLength(1); - expect(extensions[0].hooks!['PostToolUse']![0].hooks![0].command).toBe( - `echo "installed in ${extensionDir}"`, - ); + expect( + ( + extensions[0].hooks!['PostToolUse']![0].hooks![0] as { + command: string; + } + ).command, + ).toBe(`echo "installed in ${extensionDir}"`); }); it('should substitute ${CLAUDE_PLUGIN_ROOT} variable in hooks', async () => { @@ -901,9 +909,13 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].hooks).toBeDefined(); expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1); - expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe( - `${extensionDir}/scripts/setup.sh`, - ); + expect( + ( + extensions[0].hooks!['PreToolUse']![0].hooks![0] as { + command: string; + } + ).command, + ).toBe(`${extensionDir}/scripts/setup.sh`); }); it('should load hooks from config.hooks string path', async () => { @@ -955,9 +967,13 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].hooks).toBeDefined(); expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1); - expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe( - 'echo "custom hooks path"', - ); + expect( + ( + extensions[0].hooks!['PreToolUse']![0].hooks![0] as { + command: string; + } + ).command, + ).toBe('echo "custom hooks path"'); }); it('should prefer config.hooks string path over hooks/hooks.json', async () => { @@ -1013,9 +1029,13 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].hooks).toBeDefined(); - expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe( - 'echo "config path"', - ); + expect( + ( + extensions[0].hooks!['PreToolUse']![0].hooks![0] as { + command: string; + } + ).command, + ).toBe('echo "config path"'); }); it('should substitute ${CLAUDE_PLUGIN_ROOT} in hooks file from config.hooks string path', async () => { @@ -1065,9 +1085,13 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].hooks).toBeDefined(); expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1); - expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe( - `${extensionDir}/scripts/setup.sh`, - ); + expect( + ( + extensions[0].hooks!['PreToolUse']![0].hooks![0] as { + command: string; + } + ).command, + ).toBe(`${extensionDir}/scripts/setup.sh`); }); }); }); diff --git a/packages/core/src/extension/variables.test.ts b/packages/core/src/extension/variables.test.ts index 7f2366497..c16917c97 100644 --- a/packages/core/src/extension/variables.test.ts +++ b/packages/core/src/extension/variables.test.ts @@ -35,7 +35,7 @@ describe('substituteHookVariables', () => { description: 'Setup before start', hooks: [ { - type: HookType.Command, + type: HookType.Command as const, command: '${CLAUDE_PLUGIN_ROOT}/scripts/setup.sh', }, ], @@ -47,9 +47,9 @@ describe('substituteHookVariables', () => { expect(result).toBeDefined(); expect(result!['PreToolUse']).toHaveLength(1); - expect(result!['PreToolUse']![0].hooks![0].command).toBe( - '/path/to/plugin/scripts/setup.sh', - ); + expect( + (result!['PreToolUse']![0].hooks![0] as { command: string }).command, + ).toBe('/path/to/plugin/scripts/setup.sh'); }); it('should handle multiple hooks with variables', () => { @@ -61,7 +61,7 @@ describe('substituteHookVariables', () => { description: 'Post install hook 1', hooks: [ { - type: HookType.Command, + type: HookType.Command as const, command: '${CLAUDE_PLUGIN_ROOT}/bin/init.sh', }, ], @@ -70,7 +70,7 @@ describe('substituteHookVariables', () => { description: 'Post install hook 2', hooks: [ { - type: HookType.Command, + type: HookType.Command as const, command: 'chmod +x ${CLAUDE_PLUGIN_ROOT}/bin/executable.sh', }, ], @@ -82,12 +82,12 @@ describe('substituteHookVariables', () => { expect(result).toBeDefined(); expect(result!['PostToolUse']).toHaveLength(2); - expect(result!['PostToolUse']![0].hooks![0].command).toBe( - '/project/plugins/my-plugin/bin/init.sh', - ); - expect(result!['PostToolUse']![1].hooks![0].command).toBe( - 'chmod +x /project/plugins/my-plugin/bin/executable.sh', - ); + expect( + (result!['PostToolUse']![0].hooks![0] as { command: string }).command, + ).toBe('/project/plugins/my-plugin/bin/init.sh'); + expect( + (result!['PostToolUse']![1].hooks![0] as { command: string }).command, + ).toBe('chmod +x /project/plugins/my-plugin/bin/executable.sh'); }); it('should handle multiple event types with hooks', () => { @@ -101,7 +101,7 @@ describe('substituteHookVariables', () => { hooks: [ // HookConfig[] array inside HookDefinition { - type: HookType.Command, // HookType.Command + type: HookType.Command as const, // HookType.Command command: '${CLAUDE_PLUGIN_ROOT}/scripts/pre-start.sh', }, ], @@ -114,7 +114,7 @@ describe('substituteHookVariables', () => { hooks: [ // HookConfig[] array inside HookDefinition { - type: HookType.Command, // HookType.Command + type: HookType.Command as const, // HookType.Command command: '${CLAUDE_PLUGIN_ROOT}/setup/install.py', }, ], @@ -126,13 +126,14 @@ describe('substituteHookVariables', () => { expect(result).toBeDefined(); expect(result!['PreToolUse']).toHaveLength(1); - expect(result!['PreToolUse']![0].hooks![0].command).toBe( - '/home/user/.qwen/extensions/my-extension/scripts/pre-start.sh', - ); + expect( + (result!['PreToolUse']![0].hooks![0] as { command: string }).command, + ).toBe('/home/user/.qwen/extensions/my-extension/scripts/pre-start.sh'); expect(result!['UserPromptSubmit']).toHaveLength(1); - expect(result!['UserPromptSubmit']![0].hooks![0].command).toBe( - '/home/user/.qwen/extensions/my-extension/setup/install.py', - ); + expect( + (result!['UserPromptSubmit']![0].hooks![0] as { command: string }) + .command, + ).toBe('/home/user/.qwen/extensions/my-extension/setup/install.py'); }); it('should not modify non-command hooks', () => { @@ -146,7 +147,7 @@ describe('substituteHookVariables', () => { hooks: [ // This is the HookConfig[] array inside HookDefinition { - type: HookType.Command, // This is part of HookConfig + type: HookType.Command as const, // This is part of HookConfig command: '${CLAUDE_PLUGIN_ROOT}/scripts/run.sh', // This is part of HookConfig }, { @@ -162,12 +163,12 @@ describe('substituteHookVariables', () => { expect(result).toBeDefined(); expect(result!['SessionStart']).toHaveLength(1); - expect(result!['SessionStart']![0].hooks![0].command).toBe( - '/path/to/extension/scripts/run.sh', - ); - expect(result!['SessionStart']![0].hooks![1].command).toBe( - '${CLAUDE_PLUGIN_ROOT}/not-affected', - ); // Non-command type won't be processed + expect( + (result!['SessionStart']![0].hooks![0] as { command: string }).command, + ).toBe('/path/to/extension/scripts/run.sh'); + expect( + (result!['SessionStart']![0].hooks![1] as { command: string }).command, + ).toBe('${CLAUDE_PLUGIN_ROOT}/not-affected'); // Non-command type won't be processed }); it('should return undefined when hooks is undefined', () => { @@ -186,7 +187,7 @@ describe('substituteHookVariables', () => { hooks: [ // This is the HookConfig[] array inside HookDefinition { - type: HookType.Command, // This is part of CommandHookConfig + type: HookType.Command as const, // This is part of CommandHookConfig command: 'echo "hello world"', // This is part of CommandHookConfig }, ], @@ -198,7 +199,9 @@ describe('substituteHookVariables', () => { expect(result).toBeDefined(); expect(result).toEqual(hooks); // Should be equal but not the same object (deep clone) - expect(result!['Stop']![0].hooks![0].command).toBe('echo "hello world"'); + expect((result!['Stop']![0].hooks![0] as { command: string }).command).toBe( + 'echo "hello world"', + ); }); }); diff --git a/packages/core/src/hooks/asyncHookRegistry.test.ts b/packages/core/src/hooks/asyncHookRegistry.test.ts new file mode 100644 index 000000000..59b17fbb1 --- /dev/null +++ b/packages/core/src/hooks/asyncHookRegistry.test.ts @@ -0,0 +1,517 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { AsyncHookRegistry, generateHookId } from './asyncHookRegistry.js'; +import { HookEventName } from './types.js'; + +describe('AsyncHookRegistry', () => { + let registry: AsyncHookRegistry; + + beforeEach(() => { + registry = new AsyncHookRegistry(); + }); + + describe('generateHookId', () => { + it('should generate unique hook IDs', () => { + const id1 = generateHookId(); + const id2 = generateHookId(); + expect(id1).not.toBe(id2); + expect(id1).toMatch(/^hook_\d+_[a-z0-9]+$/); + }); + }); + + describe('register', () => { + it('should register a new async hook', () => { + const hookId = registry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + expect(hookId).toBe('test-hook-1'); + expect(registry.hasRunningHooks()).toBe(true); + }); + }); + + describe('updateOutput', () => { + it('should update stdout', () => { + registry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + registry.updateOutput('test-hook-1', 'stdout data', undefined); + + const pending = registry.getPendingHooks(); + expect(pending[0].stdout).toBe('stdout data'); + }); + + it('should update stderr', () => { + registry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + registry.updateOutput('test-hook-1', undefined, 'stderr data'); + + const pending = registry.getPendingHooks(); + expect(pending[0].stderr).toBe('stderr data'); + }); + }); + + describe('complete', () => { + it('should mark hook as completed and remove from pending', () => { + registry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + registry.complete('test-hook-1', { continue: true }); + + expect(registry.hasRunningHooks()).toBe(false); + }); + + it('should process JSON output for system message', () => { + registry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '{"systemMessage": "Build completed"}', + stderr: '', + }); + + registry.complete('test-hook-1'); + + const output = registry.getPendingOutput(); + expect(output.messages.length).toBe(1); + expect(output.messages[0].message).toBe('Build completed'); + expect(output.messages[0].type).toBe('system'); + }); + }); + + describe('fail', () => { + it('should mark hook as failed and add error message', () => { + registry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + registry.fail('test-hook-1', new Error('Hook failed')); + + expect(registry.hasRunningHooks()).toBe(false); + const output = registry.getPendingOutput(); + expect(output.messages.length).toBe(1); + expect(output.messages[0].type).toBe('error'); + expect(output.messages[0].message).toContain('Hook failed'); + }); + }); + + describe('timeout', () => { + it('should mark hook as timed out', () => { + registry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 1000, + stdout: '', + stderr: '', + }); + + registry.timeout('test-hook-1'); + + expect(registry.hasRunningHooks()).toBe(false); + const output = registry.getPendingOutput(); + expect(output.messages.length).toBe(1); + expect(output.messages[0].type).toBe('warning'); + expect(output.messages[0].message).toContain('timed out'); + }); + + it('should terminate process on timeout', () => { + const mockProcess = { + killed: false, + kill: vi.fn(), + once: vi.fn(), + }; + + registry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 1000, + stdout: '', + stderr: '', + process: mockProcess as unknown as import('child_process').ChildProcess, + }); + + registry.timeout('test-hook-1'); + + expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(mockProcess.once).toHaveBeenCalledWith( + 'exit', + expect.any(Function), + ); + }); + + it('should not call kill if process is already killed', () => { + const mockProcess = { + killed: true, + kill: vi.fn(), + once: vi.fn(), + }; + + registry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 1000, + stdout: '', + stderr: '', + process: mockProcess as unknown as import('child_process').ChildProcess, + }); + + registry.timeout('test-hook-1'); + + expect(mockProcess.kill).not.toHaveBeenCalled(); + }); + }); + + describe('getPendingHooks', () => { + it('should return all pending hooks', () => { + registry.register({ + hookId: 'test-hook-1', + hookName: 'Hook 1', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + registry.register({ + hookId: 'test-hook-2', + hookName: 'Hook 2', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + const pending = registry.getPendingHooks(); + expect(pending.length).toBe(2); + }); + }); + + describe('getPendingHooksForSession', () => { + it('should return hooks for specific session', () => { + registry.register({ + hookId: 'test-hook-1', + hookName: 'Hook 1', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + registry.register({ + hookId: 'test-hook-2', + hookName: 'Hook 2', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-2', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + const session1Hooks = registry.getPendingHooksForSession('session-1'); + expect(session1Hooks.length).toBe(1); + expect(session1Hooks[0].hookId).toBe('test-hook-1'); + }); + }); + + describe('getPendingOutput', () => { + it('should return and clear pending output', () => { + registry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: 'plain text output', + stderr: '', + }); + + registry.complete('test-hook-1'); + + const output1 = registry.getPendingOutput(); + expect(output1.messages.length).toBe(1); + + // Second call should return empty + const output2 = registry.getPendingOutput(); + expect(output2.messages.length).toBe(0); + }); + }); + + describe('clearSession', () => { + it('should clear all hooks for a session', () => { + registry.register({ + hookId: 'test-hook-1', + hookName: 'Hook 1', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + registry.register({ + hookId: 'test-hook-2', + hookName: 'Hook 2', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-2', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + registry.clearSession('session-1'); + + const pending = registry.getPendingHooks(); + expect(pending.length).toBe(1); + expect(pending[0].sessionId).toBe('session-2'); + }); + }); + + describe('checkTimeouts', () => { + it('should timeout expired hooks', () => { + const pastTime = Date.now() - 70000; // 70 seconds ago + + registry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: pastTime, + timeout: 60000, // 60 second timeout + stdout: '', + stderr: '', + }); + + registry.checkTimeouts(); + + expect(registry.hasRunningHooks()).toBe(false); + expect(registry.hasPendingOutput()).toBe(true); + }); + }); + + describe('concurrency limits', () => { + it('should respect maxConcurrentHooks limit', () => { + const limitedRegistry = new AsyncHookRegistry({ maxConcurrentHooks: 2 }); + + // Register first hook + const id1 = limitedRegistry.register({ + hookId: 'test-hook-1', + hookName: 'Hook 1', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + expect(id1).toBe('test-hook-1'); + + // Register second hook + const id2 = limitedRegistry.register({ + hookId: 'test-hook-2', + hookName: 'Hook 2', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + expect(id2).toBe('test-hook-2'); + + // Third hook should be rejected + const id3 = limitedRegistry.register({ + hookId: 'test-hook-3', + hookName: 'Hook 3', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + expect(id3).toBeNull(); + }); + + it('should allow registration after hook completes', () => { + const limitedRegistry = new AsyncHookRegistry({ maxConcurrentHooks: 1 }); + + // Register first hook + limitedRegistry.register({ + hookId: 'test-hook-1', + hookName: 'Hook 1', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + // Second hook should be rejected + const id2Before = limitedRegistry.register({ + hookId: 'test-hook-2', + hookName: 'Hook 2', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + expect(id2Before).toBeNull(); + + // Complete first hook + limitedRegistry.complete('test-hook-1'); + + // Now second hook should be accepted + const id2After = limitedRegistry.register({ + hookId: 'test-hook-2', + hookName: 'Hook 2', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + expect(id2After).toBe('test-hook-2'); + }); + + it('should report correct running count', () => { + const limitedRegistry = new AsyncHookRegistry({ maxConcurrentHooks: 5 }); + + expect(limitedRegistry.getRunningCount()).toBe(0); + expect(limitedRegistry.canAcceptMore()).toBe(true); + + limitedRegistry.register({ + hookId: 'test-hook-1', + hookName: 'Hook 1', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: Date.now(), + timeout: 60000, + stdout: '', + stderr: '', + }); + + expect(limitedRegistry.getRunningCount()).toBe(1); + expect(limitedRegistry.canAcceptMore()).toBe(true); + + limitedRegistry.fail('test-hook-1', new Error('test')); + + expect(limitedRegistry.getRunningCount()).toBe(0); + }); + }); + + describe('auto timeout checker', () => { + it('should start and stop timeout checker', () => { + const autoRegistry = new AsyncHookRegistry({ + enableAutoTimeoutCheck: true, + timeoutCheckInterval: 100, + }); + + // Register an expired hook + const pastTime = Date.now() - 70000; + autoRegistry.register({ + hookId: 'test-hook-1', + hookName: 'Test Hook', + hookEvent: HookEventName.PostToolUse, + sessionId: 'session-1', + startTime: pastTime, + timeout: 60000, + stdout: '', + stderr: '', + }); + + // Stop the checker to prevent interference with other tests + autoRegistry.stopTimeoutChecker(); + + // Manually check - hook should still be there since we stopped the checker + // before it could run + expect(autoRegistry.hasRunningHooks()).toBe(true); + + // Now manually trigger timeout check + autoRegistry.checkTimeouts(); + expect(autoRegistry.hasRunningHooks()).toBe(false); + }); + + it('should stop timeout checker on stopTimeoutChecker call', () => { + const autoRegistry = new AsyncHookRegistry({ + enableAutoTimeoutCheck: true, + timeoutCheckInterval: 50, + }); + + // Stop immediately + autoRegistry.stopTimeoutChecker(); + + // Should not throw or cause issues + expect(() => autoRegistry.stopTimeoutChecker()).not.toThrow(); + }); + }); +}); diff --git a/packages/core/src/hooks/asyncHookRegistry.ts b/packages/core/src/hooks/asyncHookRegistry.ts new file mode 100644 index 000000000..16237145f --- /dev/null +++ b/packages/core/src/hooks/asyncHookRegistry.ts @@ -0,0 +1,371 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger } from '../utils/debugLogger.js'; +import type { + HookOutput, + PendingAsyncHook, + AsyncHookOutputMessage, + PendingAsyncOutput, +} from './types.js'; + +const debugLogger = createDebugLogger('ASYNC_HOOK_REGISTRY'); + +/** + * Default maximum concurrent async hooks + */ +const DEFAULT_MAX_CONCURRENT_HOOKS = 10; + +/** + * Default timeout check interval (5 seconds) + */ +const DEFAULT_TIMEOUT_CHECK_INTERVAL = 5000; + +/** + * Generate a unique hook ID + */ +export function generateHookId(): string { + return `hook_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Configuration options for AsyncHookRegistry + */ +export interface AsyncHookRegistryOptions { + maxConcurrentHooks?: number; + enableAutoTimeoutCheck?: boolean; + timeoutCheckInterval?: number; +} + +/** + * Async Hook Registry - tracks and manages asynchronously executing hooks + * with concurrency limits and automatic timeout checking + */ +export class AsyncHookRegistry { + private readonly pendingHooks: Map = new Map(); + private readonly completedOutputs: AsyncHookOutputMessage[] = []; + private readonly completedContexts: string[] = []; + private readonly maxConcurrentHooks: number; + private timeoutCheckTimer: ReturnType | undefined; + + constructor(options: AsyncHookRegistryOptions = {}) { + this.maxConcurrentHooks = + options.maxConcurrentHooks ?? DEFAULT_MAX_CONCURRENT_HOOKS; + + // Start automatic timeout checking if enabled + if (options.enableAutoTimeoutCheck) { + const interval = + options.timeoutCheckInterval ?? DEFAULT_TIMEOUT_CHECK_INTERVAL; + this.startTimeoutChecker(interval); + } + } + + /** + * Start automatic timeout checking + */ + private startTimeoutChecker(interval: number): void { + if (this.timeoutCheckTimer) { + clearInterval(this.timeoutCheckTimer); + } + this.timeoutCheckTimer = setInterval(() => { + this.checkTimeouts(); + }, interval); + } + + /** + * Stop automatic timeout checking + */ + stopTimeoutChecker(): void { + if (this.timeoutCheckTimer) { + clearInterval(this.timeoutCheckTimer); + this.timeoutCheckTimer = undefined; + } + } + + /** + * Get current number of running hooks + */ + getRunningCount(): number { + return Array.from(this.pendingHooks.values()).filter( + (hook) => hook.status === 'running', + ).length; + } + + /** + * Check if we can accept more async hooks + */ + canAcceptMore(): boolean { + return this.getRunningCount() < this.maxConcurrentHooks; + } + + /** + * Register a new async hook execution + * @returns hookId if registered, null if rejected due to concurrency limit + */ + register(hook: Omit): string | null { + // Check concurrency limit + if (!this.canAcceptMore()) { + debugLogger.warn( + `Async hook registration rejected: concurrency limit reached (${this.maxConcurrentHooks})`, + ); + return null; + } + + const hookId = hook.hookId; + const pendingHook: PendingAsyncHook = { + ...hook, + status: 'running', + }; + + this.pendingHooks.set(hookId, pendingHook); + debugLogger.debug( + `Registered async hook: ${hookId} (${hook.hookName}) for event ${hook.hookEvent} [${this.getRunningCount()}/${this.maxConcurrentHooks}]`, + ); + + return hookId; + } + + /** + * Update hook output (stdout/stderr) + */ + updateOutput(hookId: string, stdout?: string, stderr?: string): void { + const hook = this.pendingHooks.get(hookId); + if (hook) { + if (stdout !== undefined) { + hook.stdout += stdout; + } + if (stderr !== undefined) { + hook.stderr += stderr; + } + } + } + + /** + * Mark a hook as completed with output + */ + complete(hookId: string, output?: HookOutput): void { + const hook = this.pendingHooks.get(hookId); + if (!hook) { + debugLogger.warn(`Attempted to complete unknown hook: ${hookId}`); + return; + } + + hook.status = 'completed'; + hook.output = output; + + // Process output for delivery + this.processCompletedOutput(hook); + + // Remove from pending + this.pendingHooks.delete(hookId); + + debugLogger.debug(`Async hook completed: ${hookId} (${hook.hookName})`); + } + + /** + * Mark a hook as failed + */ + fail(hookId: string, error: Error): void { + const hook = this.pendingHooks.get(hookId); + if (!hook) { + debugLogger.warn(`Attempted to fail unknown hook: ${hookId}`); + return; + } + + hook.status = 'failed'; + hook.error = error; + + // Add error message to outputs + this.completedOutputs.push({ + type: 'error', + message: `Async hook ${hook.hookName} failed: ${error.message}`, + hookName: hook.hookName, + hookId, + timestamp: Date.now(), + }); + + // Remove from pending + this.pendingHooks.delete(hookId); + + debugLogger.debug(`Async hook failed: ${hookId} (${hook.hookName})`); + } + + /** + * Mark a hook as timed out and terminate the process if running + */ + timeout(hookId: string): void { + const hook = this.pendingHooks.get(hookId); + if (!hook) { + debugLogger.warn(`Attempted to timeout unknown hook: ${hookId}`); + return; + } + + // Terminate the process if it's still running + if (hook.process && !hook.process.killed) { + debugLogger.debug(`Terminating process for timed out hook: ${hookId}`); + // First try graceful termination with SIGTERM + hook.process.kill('SIGTERM'); + // Force kill with SIGKILL after 2 seconds if still running + const forceKillTimeout = setTimeout(() => { + if (hook.process && !hook.process.killed) { + debugLogger.debug(`Force killing process for hook: ${hookId}`); + hook.process.kill('SIGKILL'); + } + }, 2000); + // Clean up the timeout if process exits + hook.process.once('exit', () => { + clearTimeout(forceKillTimeout); + }); + } + + hook.status = 'timeout'; + hook.error = new Error(`Hook timed out after ${hook.timeout}ms`); + + // Add timeout message to outputs + this.completedOutputs.push({ + type: 'warning', + message: `Async hook ${hook.hookName} timed out after ${hook.timeout}ms`, + hookName: hook.hookName, + hookId, + timestamp: Date.now(), + }); + + // Remove from pending + this.pendingHooks.delete(hookId); + + debugLogger.debug(`Async hook timed out: ${hookId} (${hook.hookName})`); + } + + /** + * Get all pending hooks + */ + getPendingHooks(): PendingAsyncHook[] { + return Array.from(this.pendingHooks.values()); + } + + /** + * Get pending hooks for a specific session + */ + getPendingHooksForSession(sessionId: string): PendingAsyncHook[] { + return Array.from(this.pendingHooks.values()).filter( + (hook) => hook.sessionId === sessionId, + ); + } + + /** + * Get and clear pending output for delivery to the next turn + */ + getPendingOutput(): PendingAsyncOutput { + const output: PendingAsyncOutput = { + messages: [...this.completedOutputs], + contexts: [...this.completedContexts], + }; + + // Clear after retrieval + this.completedOutputs.length = 0; + this.completedContexts.length = 0; + + return output; + } + + /** + * Check if there are any pending outputs + */ + hasPendingOutput(): boolean { + return ( + this.completedOutputs.length > 0 || this.completedContexts.length > 0 + ); + } + + /** + * Check if there are any running hooks + */ + hasRunningHooks(): boolean { + return this.pendingHooks.size > 0; + } + + /** + * Check for timed out hooks and mark them + */ + checkTimeouts(): void { + const now = Date.now(); + for (const [hookId, hook] of this.pendingHooks.entries()) { + if (hook.status === 'running' && now - hook.startTime > hook.timeout) { + this.timeout(hookId); + } + } + } + + /** + * Clear all pending hooks for a session (e.g., on session end) + */ + clearSession(sessionId: string): void { + for (const [hookId, hook] of this.pendingHooks.entries()) { + if (hook.sessionId === sessionId) { + this.pendingHooks.delete(hookId); + debugLogger.debug( + `Cleared async hook on session end: ${hookId} (${hook.hookName})`, + ); + } + } + } + + /** + * Process completed hook output for delivery + */ + private processCompletedOutput(hook: PendingAsyncHook): void { + // Parse stdout for JSON output + if (hook.stdout) { + try { + const parsed = JSON.parse(hook.stdout.trim()); + + // Extract system message + if (parsed.systemMessage && typeof parsed.systemMessage === 'string') { + this.completedOutputs.push({ + type: 'system', + message: parsed.systemMessage, + hookName: hook.hookName, + hookId: hook.hookId, + timestamp: Date.now(), + }); + } + + // Extract additional context + if ( + parsed.hookSpecificOutput?.additionalContext && + typeof parsed.hookSpecificOutput.additionalContext === 'string' + ) { + this.completedContexts.push( + parsed.hookSpecificOutput.additionalContext, + ); + } + } catch { + // Not JSON, treat as plain text message if non-empty + const trimmed = hook.stdout.trim(); + if (trimmed) { + this.completedOutputs.push({ + type: 'info', + message: trimmed, + hookName: hook.hookName, + hookId: hook.hookId, + timestamp: Date.now(), + }); + } + } + } + + // Add stderr as warning if present + if (hook.stderr && hook.stderr.trim()) { + this.completedOutputs.push({ + type: 'warning', + message: hook.stderr.trim(), + hookName: hook.hookName, + hookId: hook.hookId, + timestamp: Date.now(), + }); + } + } +} diff --git a/packages/core/src/hooks/combinedAbortSignal.test.ts b/packages/core/src/hooks/combinedAbortSignal.test.ts new file mode 100644 index 000000000..033ae3c2e --- /dev/null +++ b/packages/core/src/hooks/combinedAbortSignal.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { createCombinedAbortSignal } from './combinedAbortSignal.js'; + +describe('createCombinedAbortSignal', () => { + it('should return a non-aborted signal by default', () => { + const { signal, cleanup } = createCombinedAbortSignal(); + expect(signal.aborted).toBe(false); + cleanup(); + }); + + it('should abort after timeout', async () => { + const { signal, cleanup } = createCombinedAbortSignal(undefined, { + timeoutMs: 50, + }); + expect(signal.aborted).toBe(false); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(signal.aborted).toBe(true); + cleanup(); + }); + + it('should abort when external signal is aborted', () => { + const externalController = new AbortController(); + const { signal, cleanup } = createCombinedAbortSignal( + externalController.signal, + ); + expect(signal.aborted).toBe(false); + + externalController.abort(); + expect(signal.aborted).toBe(true); + cleanup(); + }); + + it('should abort immediately if external signal is already aborted', () => { + const externalController = new AbortController(); + externalController.abort(); + + const { signal, cleanup } = createCombinedAbortSignal( + externalController.signal, + ); + expect(signal.aborted).toBe(true); + cleanup(); + }); + + it('should cleanup timeout timer', async () => { + const { signal, cleanup } = createCombinedAbortSignal(undefined, { + timeoutMs: 50, + }); + + cleanup(); + + // Wait longer than timeout - should not abort because timer was cleared + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(signal.aborted).toBe(false); + }); + + it('should work with both external signal and timeout', async () => { + const externalController = new AbortController(); + const { signal, cleanup } = createCombinedAbortSignal( + externalController.signal, + { timeoutMs: 200 }, + ); + + // Abort external signal before timeout + externalController.abort(); + expect(signal.aborted).toBe(true); + cleanup(); + }); + + it('should timeout before external signal', async () => { + const externalController = new AbortController(); + const { signal, cleanup } = createCombinedAbortSignal( + externalController.signal, + { timeoutMs: 50 }, + ); + + // Wait for timeout + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(signal.aborted).toBe(true); + + // External signal is still not aborted + expect(externalController.signal.aborted).toBe(false); + cleanup(); + }); +}); diff --git a/packages/core/src/hooks/combinedAbortSignal.ts b/packages/core/src/hooks/combinedAbortSignal.ts new file mode 100644 index 000000000..d8dccf64c --- /dev/null +++ b/packages/core/src/hooks/combinedAbortSignal.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Create a combined AbortSignal that aborts when either: + * - The provided external signal is aborted, OR + * - The timeout is reached + * + * @param externalSignal - Optional external AbortSignal to combine + * @param timeoutMs - Timeout in milliseconds + * @returns Object containing the combined signal and a cleanup function + */ +export function createCombinedAbortSignal( + externalSignal?: AbortSignal, + options?: { timeoutMs?: number }, +): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController(); + + const timeoutMs = options?.timeoutMs; + + // Set up timeout + let timeoutId: ReturnType | undefined; + if (timeoutMs !== undefined && timeoutMs > 0) { + timeoutId = setTimeout(() => { + controller.abort(); + }, timeoutMs); + } + + // Listen to external signal + if (externalSignal) { + if (externalSignal.aborted) { + controller.abort(); + } else { + const abortHandler = () => { + controller.abort(); + }; + externalSignal.addEventListener('abort', abortHandler, { once: true }); + } + } + + const cleanup = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + }; + + return { signal: controller.signal, cleanup }; +} diff --git a/packages/core/src/hooks/envInterpolator.test.ts b/packages/core/src/hooks/envInterpolator.test.ts new file mode 100644 index 000000000..043a4f85e --- /dev/null +++ b/packages/core/src/hooks/envInterpolator.test.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + interpolateEnvVars, + interpolateHeaders, + interpolateUrl, + hasEnvVarReferences, + extractEnvVarNames, + sanitizeHeaderValue, +} from './envInterpolator.js'; + +describe('envInterpolator', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env['MY_TOKEN'] = 'secret-token'; + process.env['API_KEY'] = 'api-key-123'; + process.env['EMPTY_VAR'] = ''; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('interpolateEnvVars', () => { + it('should replace allowed environment variables with $VAR syntax', () => { + const result = interpolateEnvVars('Bearer $MY_TOKEN', ['MY_TOKEN']); + expect(result).toBe('Bearer secret-token'); + }); + + it('should replace allowed environment variables with ${VAR} syntax', () => { + const result = interpolateEnvVars('Bearer ${MY_TOKEN}', ['MY_TOKEN']); + expect(result).toBe('Bearer secret-token'); + }); + + it('should replace variables not in whitelist with empty string', () => { + const result = interpolateEnvVars('Bearer $OTHER_VAR', ['MY_TOKEN']); + expect(result).toBe('Bearer '); + }); + + it('should handle multiple variables', () => { + const result = interpolateEnvVars('$MY_TOKEN:$API_KEY', [ + 'MY_TOKEN', + 'API_KEY', + ]); + expect(result).toBe('secret-token:api-key-123'); + }); + + it('should handle mixed allowed and disallowed variables', () => { + const result = interpolateEnvVars('$MY_TOKEN:$OTHER_VAR', ['MY_TOKEN']); + expect(result).toBe('secret-token:'); + }); + + it('should handle undefined environment variables', () => { + const result = interpolateEnvVars('$UNDEFINED_VAR', ['UNDEFINED_VAR']); + expect(result).toBe(''); + }); + + it('should handle empty whitelist', () => { + const result = interpolateEnvVars('$MY_TOKEN', []); + expect(result).toBe(''); + }); + + it('should not replace text without $ prefix', () => { + const result = interpolateEnvVars('MY_TOKEN', ['MY_TOKEN']); + expect(result).toBe('MY_TOKEN'); + }); + + it('should sanitize CR characters to prevent header injection', () => { + process.env['EVIL_TOKEN'] = 'good\r\nX-Evil: injected'; + const result = interpolateEnvVars('$EVIL_TOKEN', ['EVIL_TOKEN']); + expect(result).toBe('goodX-Evil: injected'); + }); + + it('should sanitize LF characters to prevent header injection', () => { + process.env['EVIL_TOKEN'] = 'good\nX-Evil: injected'; + const result = interpolateEnvVars('$EVIL_TOKEN', ['EVIL_TOKEN']); + expect(result).toBe('goodX-Evil: injected'); + }); + + it('should sanitize NUL characters', () => { + process.env['EVIL_TOKEN'] = 'good\x00bad'; + const result = interpolateEnvVars('$EVIL_TOKEN', ['EVIL_TOKEN']); + expect(result).toBe('goodbad'); + }); + + it('should sanitize CRLF and NUL combined', () => { + process.env['EVIL_TOKEN'] = 'token\r\nX-Injected: 1\x00more'; + const result = interpolateEnvVars('Bearer $EVIL_TOKEN', ['EVIL_TOKEN']); + expect(result).toBe('Bearer tokenX-Injected: 1more'); + }); + }); + + describe('interpolateHeaders', () => { + it('should interpolate all header values', () => { + const headers = { + Authorization: 'Bearer $MY_TOKEN', + 'X-API-Key': '$API_KEY', + 'Content-Type': 'application/json', + }; + const result = interpolateHeaders(headers, ['MY_TOKEN', 'API_KEY']); + expect(result).toEqual({ + Authorization: 'Bearer secret-token', + 'X-API-Key': 'api-key-123', + 'Content-Type': 'application/json', + }); + }); + + it('should handle empty headers', () => { + const result = interpolateHeaders({}, ['MY_TOKEN']); + expect(result).toEqual({}); + }); + }); + + describe('interpolateUrl', () => { + it('should interpolate URL with environment variables', () => { + process.env['API_HOST'] = 'api.example.com'; + const result = interpolateUrl('https://$API_HOST/v1/hook', ['API_HOST']); + expect(result).toBe('https://api.example.com/v1/hook'); + }); + }); + + describe('hasEnvVarReferences', () => { + it('should return true for $VAR syntax', () => { + expect(hasEnvVarReferences('$MY_TOKEN')).toBe(true); + }); + + it('should return true for ${VAR} syntax', () => { + expect(hasEnvVarReferences('${MY_TOKEN}')).toBe(true); + }); + + it('should return false for plain text', () => { + expect(hasEnvVarReferences('plain text')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(hasEnvVarReferences('')).toBe(false); + }); + }); + + describe('extractEnvVarNames', () => { + it('should extract single variable name', () => { + expect(extractEnvVarNames('$MY_TOKEN')).toEqual(['MY_TOKEN']); + }); + + it('should extract multiple variable names', () => { + expect(extractEnvVarNames('$MY_TOKEN:$API_KEY')).toEqual([ + 'MY_TOKEN', + 'API_KEY', + ]); + }); + + it('should extract from ${VAR} syntax', () => { + expect(extractEnvVarNames('${MY_TOKEN}')).toEqual(['MY_TOKEN']); + }); + + it('should not duplicate variable names', () => { + expect(extractEnvVarNames('$MY_TOKEN:$MY_TOKEN')).toEqual(['MY_TOKEN']); + }); + + it('should return empty array for no variables', () => { + expect(extractEnvVarNames('plain text')).toEqual([]); + }); + }); + + describe('sanitizeHeaderValue', () => { + it('should strip CR characters', () => { + expect(sanitizeHeaderValue('token\r\nX-Evil: 1')).toBe('tokenX-Evil: 1'); + }); + + it('should strip LF characters', () => { + expect(sanitizeHeaderValue('token\nX-Evil: 1')).toBe('tokenX-Evil: 1'); + }); + + it('should strip NUL characters', () => { + expect(sanitizeHeaderValue('good\x00bad')).toBe('goodbad'); + }); + + it('should strip all three dangerous characters', () => { + expect(sanitizeHeaderValue('a\r\nb\x00c')).toBe('abc'); + }); + + it('should not affect safe values', () => { + expect(sanitizeHeaderValue('Bearer abc123')).toBe('Bearer abc123'); + }); + + it('should handle empty string', () => { + expect(sanitizeHeaderValue('')).toBe(''); + }); + }); +}); diff --git a/packages/core/src/hooks/envInterpolator.ts b/packages/core/src/hooks/envInterpolator.ts new file mode 100644 index 000000000..5f04781e4 --- /dev/null +++ b/packages/core/src/hooks/envInterpolator.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Environment variable interpolation utilities for HTTP hooks. + * Provides secure interpolation with whitelist-based access control. + */ + +/** + * Strip CR, LF, and NUL bytes from a header value to prevent HTTP header + * injection (CRLF injection) via env var values or hook-configured header + * templates. A malicious env var like "token\r\nX-Evil: 1" would otherwise + * inject a second header into the request. + * + * Aligned with Claude Code's sanitizeHeaderValue behavior. + */ +export function sanitizeHeaderValue(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\r\n\x00]/g, ''); +} + +/** + * Interpolate environment variables in a string value. + * Only variables in the allowedVars list will be replaced. + * Variables not in the whitelist will be replaced with empty string. + * + * Supports both $VAR_NAME and ${VAR_NAME} syntax. + * + * @param value - The string containing environment variable references + * @param allowedVars - List of allowed environment variable names + * @returns The interpolated string (sanitized to prevent header injection) + */ +/** + * Dangerous variable names that could be used for prototype pollution attacks + */ +const DANGEROUS_VAR_NAMES = [ + '__proto__', + 'constructor', + 'prototype', + '__defineGetter__', + '__defineSetter__', + '__lookupGetter__', + '__lookupSetter__', +]; + +/** + * Check if a variable name is safe (not a prototype pollution vector) + */ +function isSafeVarName(varName: string): boolean { + return !DANGEROUS_VAR_NAMES.includes(varName); +} + +export function interpolateEnvVars( + value: string, + allowedVars: string[], +): string { + // Match $VAR_NAME or ${VAR_NAME} + const interpolated = value.replace( + /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, + (match, varName: string) => { + // Block dangerous variable names to prevent prototype pollution + if (!isSafeVarName(varName)) { + return ''; + } + if (allowedVars.includes(varName)) { + return process.env[varName] || ''; + } + // Not in whitelist, replace with empty string for security + return ''; + }, + ); + // Sanitize to prevent CRLF/NUL header injection + return sanitizeHeaderValue(interpolated); +} + +/** + * Interpolate environment variables in all header values. + * + * @param headers - Record of header name to value + * @param allowedVars - List of allowed environment variable names + * @returns New headers record with interpolated values + */ +export function interpolateHeaders( + headers: Record, + allowedVars: string[], +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + result[key] = interpolateEnvVars(value, allowedVars); + } + return result; +} + +/** + * Interpolate environment variables in a URL. + * + * @param url - The URL string containing environment variable references + * @param allowedVars - List of allowed environment variable names + * @returns The interpolated URL + */ +export function interpolateUrl(url: string, allowedVars: string[]): string { + return interpolateEnvVars(url, allowedVars); +} + +/** + * Check if a string contains environment variable references. + * + * @param value - The string to check + * @returns True if the string contains env var references + */ +export function hasEnvVarReferences(value: string): boolean { + return /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/.test(value); +} + +/** + * Extract all environment variable names referenced in a string. + * + * @param value - The string to extract from + * @returns Array of environment variable names + */ +export function extractEnvVarNames(value: string): string[] { + const matches = value.matchAll(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g); + const names: string[] = []; + for (const match of matches) { + if (match[1] && !names.includes(match[1])) { + names.push(match[1]); + } + } + return names; +} diff --git a/packages/core/src/hooks/functionHookRunner.test.ts b/packages/core/src/hooks/functionHookRunner.test.ts new file mode 100644 index 000000000..a8426a382 --- /dev/null +++ b/packages/core/src/hooks/functionHookRunner.test.ts @@ -0,0 +1,432 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FunctionHookRunner } from './functionHookRunner.js'; +import { HookEventName, HookType } from './types.js'; +import type { FunctionHookConfig, HookInput, HookOutput } from './types.js'; + +describe('FunctionHookRunner', () => { + let functionRunner: FunctionHookRunner; + + beforeEach(() => { + functionRunner = new FunctionHookRunner(); + vi.clearAllMocks(); + }); + + const createMockInput = (overrides: Partial = {}): HookInput => ({ + session_id: 'test-session', + transcript_path: '/test/transcript', + cwd: '/test', + hook_event_name: 'PreToolUse', + timestamp: '2024-01-01T00:00:00Z', + ...overrides, + }); + + const createMockConfig = ( + callback: FunctionHookConfig['callback'], + overrides: Partial = {}, + ): FunctionHookConfig => ({ + type: HookType.Function, + callback, + errorMessage: 'Hook failed', + ...overrides, + }); + + describe('execute', () => { + it('should execute callback successfully', async () => { + const mockCallback = vi.fn().mockResolvedValue({ + decision: 'allow', + reason: 'Approved', + } as HookOutput); + + const config = createMockConfig(mockCallback); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.outcome).toBe('success'); + expect(result.output?.decision).toBe('allow'); + expect(mockCallback).toHaveBeenCalledWith(input, undefined); + }); + + it('should handle callback returning undefined', async () => { + const mockCallback = vi.fn().mockResolvedValue(undefined); + + const config = createMockConfig(mockCallback); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.output).toEqual({ continue: true }); + }); + + it('should handle callback throwing error', async () => { + const mockCallback = vi + .fn() + .mockRejectedValue(new Error('Callback error')); + + const config = createMockConfig(mockCallback, { + errorMessage: 'Custom error message', + }); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.error?.message).toContain('Custom error message'); + expect(result.error?.message).toContain('Callback error'); + }); + + it('should handle timeout', async () => { + const mockCallback = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ continue: true }), 1000); + }), + ); + + const config = createMockConfig(mockCallback, { timeout: 10 }); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.error?.message).toContain('timed out'); + }); + + it('should handle abort signal', async () => { + const controller = new AbortController(); + controller.abort(); + + const mockCallback = vi.fn().mockResolvedValue({ continue: true }); + const config = createMockConfig(mockCallback); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + { signal: controller.signal }, + ); + + expect(result.success).toBe(false); + expect(result.error?.message).toContain('cancelled'); + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it('should pass correct input to callback', async () => { + const mockCallback = vi.fn().mockResolvedValue({ continue: true }); + + const config = createMockConfig(mockCallback); + const input = createMockInput({ + session_id: 'custom-session', + cwd: '/custom/path', + }); + + await functionRunner.execute(config, HookEventName.PreToolUse, input); + + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + session_id: 'custom-session', + cwd: '/custom/path', + }), + undefined, + ); + }); + + it('should include hook id in result', async () => { + const mockCallback = vi.fn().mockResolvedValue({ continue: true }); + + const config = createMockConfig(mockCallback, { + id: 'my-hook-id', + name: 'My Hook', + }); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.hookConfig).toEqual(config); + }); + + it('should reject invalid callback', async () => { + const config = createMockConfig( + 'not a function' as unknown as FunctionHookConfig['callback'], + ); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.error?.message).toContain('Invalid callback'); + }); + + it('should handle abort signal during execution', async () => { + const controller = new AbortController(); + const mockCallback = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + // Abort after a short delay + setTimeout(() => { + controller.abort(); + }, 10); + // Resolve after a longer delay + setTimeout(() => resolve({ continue: true }), 100); + }), + ); + + const config = createMockConfig(mockCallback, { timeout: 5000 }); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + { signal: controller.signal }, + ); + + expect(result.success).toBe(false); + expect(result.error?.message).toContain('aborted'); + }); + + it('should properly clean up resources on success', async () => { + const mockCallback = vi.fn().mockResolvedValue({ continue: true }); + + const config = createMockConfig(mockCallback, { timeout: 5000 }); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + // No timeout should fire after success + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(result.success).toBe(true); + }); + + it('should support boolean semantics (true=success)', async () => { + const mockCallback = vi.fn().mockResolvedValue(true); + const config = createMockConfig(mockCallback); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.outcome).toBe('success'); + expect(result.output).toEqual({ continue: true }); + }); + + it('should support boolean semantics (false=blocking)', async () => { + const mockCallback = vi.fn().mockResolvedValue(false); + const config = createMockConfig(mockCallback, { + errorMessage: 'Validation failed', + }); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.outcome).toBe('blocking'); + expect(result.output?.continue).toBe(false); + expect(result.output?.decision).toBe('block'); + expect(result.output?.reason).toBe('Validation failed'); + }); + + it('should pass context to callback', async () => { + const mockCallback = vi.fn().mockResolvedValue(true); + const config = createMockConfig(mockCallback); + const input = createMockInput(); + const messages = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there' }, + ]; + + await functionRunner.execute(config, HookEventName.PreToolUse, input, { + messages, + toolUseID: 'tool-123', + }); + + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + session_id: 'test-session', + cwd: '/test', + }), + { + messages, + toolUseID: 'tool-123', + signal: undefined, + }, + ); + }); + + it('should call onHookSuccess callback on success', async () => { + const mockCallback = vi.fn().mockResolvedValue(true); + const onSuccess = vi.fn(); + const config = createMockConfig(mockCallback, { + onHookSuccess: onSuccess, + }); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(onSuccess).toHaveBeenCalledWith(result); + }); + + it('should not call onHookSuccess on failure', async () => { + const mockCallback = vi.fn().mockRejectedValue(new Error('Test error')); + const onSuccess = vi.fn(); + const config = createMockConfig(mockCallback, { + errorMessage: 'Hook failed', + onHookSuccess: onSuccess, + }); + const input = createMockInput(); + + await functionRunner.execute(config, HookEventName.PreToolUse, input); + + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('should handle onHookSuccess error gracefully', async () => { + const mockCallback = vi.fn().mockResolvedValue(true); + const onSuccess = vi.fn().mockImplementation(() => { + throw new Error('Success callback error'); + }); + const config = createMockConfig(mockCallback, { + onHookSuccess: onSuccess, + }); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('should determine outcome from HookOutput decision', async () => { + const mockCallback = vi.fn().mockResolvedValue({ + decision: 'block', + reason: 'Security violation', + }); + const config = createMockConfig(mockCallback); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.outcome).toBe('blocking'); + expect(result.output?.decision).toBe('block'); + }); + + it('should determine outcome from HookOutput continue=false', async () => { + const mockCallback = vi.fn().mockResolvedValue({ + continue: false, + stopReason: 'Please stop', + }); + const config = createMockConfig(mockCallback); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.outcome).toBe('blocking'); + expect(result.output?.continue).toBe(false); + }); + + it('should treat undefined return as success', async () => { + const mockCallback = vi.fn().mockResolvedValue(undefined); + const config = createMockConfig(mockCallback); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.outcome).toBe('success'); + expect(result.output).toEqual({ continue: true }); + }); + + it('should handle async callback with context', async () => { + const mockCallback = vi + .fn() + .mockImplementation(async (_input, context) => { + expect(context).toBeDefined(); + expect(context?.messages).toEqual([{ role: 'user' }]); + return true; + }); + + const config = createMockConfig(mockCallback); + const input = createMockInput(); + + const result = await functionRunner.execute( + config, + HookEventName.PreToolUse, + input, + { messages: [{ role: 'user' }] }, + ); + + expect(result.success).toBe(true); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/core/src/hooks/functionHookRunner.ts b/packages/core/src/hooks/functionHookRunner.ts new file mode 100644 index 000000000..badcd344c --- /dev/null +++ b/packages/core/src/hooks/functionHookRunner.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger } from '../utils/debugLogger.js'; +import type { + FunctionHookConfig, + HookInput, + HookOutput, + HookExecutionResult, + HookEventName, + FunctionHookContext, + HookExecutionOutcome, +} from './types.js'; + +const debugLogger = createDebugLogger('FUNCTION_HOOK_RUNNER'); + +/** + * Default timeout for function hook execution (5 seconds) + * Function hooks are intended for quick validation checks + */ +const DEFAULT_FUNCTION_TIMEOUT = 5000; + +/** + * Function Hook Runner - executes function hooks (callbacks) + * Used primarily for Session Hooks registered via SDK + */ +export class FunctionHookRunner { + /** + * Execute a function hook + * @param hookConfig Function hook configuration + * @param eventName Event name + * @param input Hook input + * @param context Optional context (messages, toolUseID, signal) + */ + async execute( + hookConfig: FunctionHookConfig, + eventName: HookEventName, + input: HookInput, + context?: FunctionHookContext, + ): Promise { + const startTime = Date.now(); + const hookId = hookConfig.id || hookConfig.name || 'anonymous-function'; + const signal = context?.signal; + + // Check if already aborted + if (signal?.aborted) { + return { + hookConfig, + eventName, + success: false, + outcome: 'cancelled', + error: new Error( + `Function hook execution cancelled (aborted): ${hookId}`, + ), + duration: 0, + }; + } + + try { + const timeout = hookConfig.timeout ?? DEFAULT_FUNCTION_TIMEOUT; + + // Execute callback with timeout and context + const result = await this.executeWithTimeout( + hookConfig.callback, + input, + context, + timeout, + signal, + ); + + const duration = Date.now() - startTime; + + debugLogger.debug( + `Function hook ${hookId} completed successfully in ${duration}ms`, + ); + + // Process the callback result + const executionResult = this.processHookResult( + hookConfig, + eventName, + result, + duration, + ); + + // Invoke success callback if provided + if (executionResult.success && hookConfig.onHookSuccess) { + try { + hookConfig.onHookSuccess(executionResult); + } catch (error) { + debugLogger.warn( + `onHookSuccess callback failed for ${hookId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return executionResult; + } catch (error) { + const duration = Date.now() - startTime; + const errorMessage = + error instanceof Error ? error.message : String(error); + + debugLogger.warn(`Function hook ${hookId} failed: ${errorMessage}`); + + // Use configured error message if available + const displayError = hookConfig.errorMessage + ? new Error(`${hookConfig.errorMessage}: ${errorMessage}`) + : error instanceof Error + ? error + : new Error(errorMessage); + + return { + hookConfig, + eventName, + success: false, + outcome: 'non_blocking_error', + error: displayError, + duration, + }; + } + } + + /** + * Process hook result and convert to execution result + */ + private processHookResult( + hookConfig: FunctionHookConfig, + eventName: HookEventName, + result: HookOutput | boolean | undefined, + duration: number, + ): HookExecutionResult { + // Boolean semantics: true=success, false=blocking + if (typeof result === 'boolean') { + if (result) { + return { + hookConfig, + eventName, + success: true, + outcome: 'success', + output: { continue: true }, + duration, + }; + } else { + return { + hookConfig, + eventName, + success: false, + outcome: 'blocking', + output: { + continue: false, + stopReason: hookConfig.errorMessage || 'Blocked by function hook', + decision: 'block', + reason: hookConfig.errorMessage || 'Blocked by function hook', + }, + duration, + }; + } + } + + // HookOutput semantics (advanced) + const output = result || { continue: true }; + const outcome: HookExecutionOutcome = this.determineOutcome(output); + + return { + hookConfig, + eventName, + success: outcome === 'success', + outcome, + output, + duration, + }; + } + + /** + * Determine outcome from HookOutput + */ + private determineOutcome(output: HookOutput): HookExecutionOutcome { + if (output.decision === 'block' || output.decision === 'deny') { + return 'blocking'; + } + if (output.continue === false) { + return 'blocking'; + } + return 'success'; + } + + /** + * Execute callback with timeout support using Promise.race for proper race condition handling + */ + private async executeWithTimeout( + callback: FunctionHookConfig['callback'], + input: HookInput, + context: FunctionHookContext | undefined, + timeout: number, + signal?: AbortSignal, + ): Promise { + // Validate callback + if (typeof callback !== 'function') { + throw new Error('Invalid callback: expected a function'); + } + + let timeoutId: ReturnType | undefined; + let abortHandler: (() => void) | undefined; + + // Cleanup function to ensure all resources are released + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + if (signal && abortHandler) { + signal.removeEventListener('abort', abortHandler); + abortHandler = undefined; + } + }; + + try { + // Create timeout promise + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Function hook timed out after ${timeout}ms`)); + }, timeout); + }); + + // Create abort promise + const abortPromise = new Promise((_, reject) => { + if (signal) { + if (signal.aborted) { + reject(new Error('Function hook execution aborted')); + return; + } + abortHandler = () => { + reject(new Error('Function hook execution aborted')); + }; + signal.addEventListener('abort', abortHandler); + } + }); + + // Race between callback execution, timeout, and abort + const promises: Array> = + [callback(input, context), timeoutPromise]; + + if (signal) { + promises.push(abortPromise); + } + + const result = await Promise.race(promises); + cleanup(); + return result; + } catch (error) { + cleanup(); + throw error; + } + } +} diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts index 996c683c0..b9f8aebfa 100644 --- a/packages/core/src/hooks/hookEventHandler.test.ts +++ b/packages/core/src/hooks/hookEventHandler.test.ts @@ -25,6 +25,7 @@ import type { HookRunner, HookAggregator, AggregatedHookResult, + SessionHooksManager, } from './index.js'; import type { HookConfig, HookOutput, PermissionSuggestion } from './types.js'; import type { HookExecutionResult } from './types.js'; @@ -40,6 +41,7 @@ describe('HookEventHandler', () => { let mockHookPlanner: HookPlanner; let mockHookRunner: HookRunner; let mockHookAggregator: HookAggregator; + let mockSessionHooksManager: SessionHooksManager; let hookEventHandler: HookEventHandler; beforeEach(() => { @@ -62,11 +64,26 @@ describe('HookEventHandler', () => { aggregateResults: vi.fn(), } as unknown as HookAggregator; + mockSessionHooksManager = { + getMatchingHooks: vi.fn().mockReturnValue([]), + getHooksForEvent: vi.fn().mockReturnValue([]), + hasSessionHooks: vi.fn().mockReturnValue(false), + addSessionHook: vi.fn(), + addFunctionHook: vi.fn(), + removeHook: vi.fn(), + removeFunctionHook: vi.fn(), + clearSessionHooks: vi.fn(), + getActiveSessions: vi.fn().mockReturnValue([]), + getHookCount: vi.fn().mockReturnValue(0), + getAllSessionHooks: vi.fn().mockReturnValue([]), + } as unknown as SessionHooksManager; + hookEventHandler = new HookEventHandler( mockConfig, mockHookPlanner, mockHookRunner, mockHookAggregator, + mockSessionHooksManager, ); }); @@ -722,6 +739,10 @@ describe('HookEventHandler', () => { expect.any(Function), // onHookStart callback expect.any(Function), // onHookEnd callback undefined, // signal + expect.objectContaining({ + messages: undefined, + toolUseID: 'toolu_test111', + }), // functionContext ); }); @@ -2946,4 +2967,134 @@ describe('HookEventHandler', () => { ); }); }); + + describe('MessagesProvider integration', () => { + it('should accept messagesProvider in constructor', () => { + const messagesProvider = vi + .fn() + .mockReturnValue([{ role: 'user', content: 'Hello' }]); + + const handler = new HookEventHandler( + mockConfig, + mockHookPlanner, + mockHookRunner, + mockHookAggregator, + mockSessionHooksManager, + messagesProvider, + ); + + expect(handler.getMessagesProvider()).toBe(messagesProvider); + }); + + it('should set messagesProvider via setMessagesProvider', () => { + hookEventHandler.setMessagesProvider(vi.fn().mockReturnValue([])); + expect(hookEventHandler.getMessagesProvider()).toBeDefined(); + }); + + it('should pass messages to function hooks via context', async () => { + const messages = [{ role: 'user', content: 'Test message' }]; + const messagesProvider = vi.fn().mockReturnValue(messages); + + hookEventHandler.setMessagesProvider(messagesProvider); + + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + + await hookEventHandler.firePreToolUseEvent( + 'Bash', + { command: 'ls' }, + 'toolu_test', + PermissionMode.Default, + ); + + // Verify context was passed with messages + expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( + expect.any(Array), + HookEventName.PreToolUse, + expect.any(Object), + expect.any(Function), + expect.any(Function), + undefined, + expect.objectContaining({ + messages, + toolUseID: 'toolu_test', + }), + ); + }); + + it('should pass toolUseID from input to context', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + + await hookEventHandler.firePostToolUseEvent( + 'Write', + { file_path: '/test.txt' }, + { content: 'test' }, + 'toolu_12345', + PermissionMode.Default, + ); + + expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( + expect.any(Array), + HookEventName.PostToolUse, + expect.any(Object), + expect.any(Function), + expect.any(Function), + undefined, + expect.objectContaining({ + toolUseID: 'toolu_12345', + }), + ); + }); + + it('should handle undefined messagesProvider', async () => { + // No messagesProvider set + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + + await hookEventHandler.firePreToolUseEvent( + 'Bash', + { command: 'ls' }, + 'toolu_test', + PermissionMode.Default, + ); + + expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( + expect.any(Array), + HookEventName.PreToolUse, + expect.any(Object), + expect.any(Function), + expect.any(Function), + undefined, + expect.objectContaining({ + messages: undefined, + toolUseID: 'toolu_test', + }), + ); + }); + }); }); diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index 97baf23f6..567a367ed 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -8,6 +8,7 @@ import type { Config } from '../config/config.js'; import type { HookPlanner, HookEventContext } from './hookPlanner.js'; import type { HookRunner } from './hookRunner.js'; import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js'; +import type { SessionHooksManager } from './sessionHooksManager.js'; import { HookEventName } from './types.js'; import type { HookConfig, @@ -33,6 +34,8 @@ import type { PermissionSuggestion, SubagentStartInput, SubagentStopInput, + MessagesProvider, + FunctionHookContext, StopFailureInput, StopFailureErrorType, } from './types.js'; @@ -51,17 +54,38 @@ export class HookEventHandler { private readonly hookPlanner: HookPlanner; private readonly hookRunner: HookRunner; private readonly hookAggregator: HookAggregator; + private readonly sessionHooksManager: SessionHooksManager; + /** Optional provider for conversation history */ + private messagesProvider?: MessagesProvider; constructor( config: Config, hookPlanner: HookPlanner, hookRunner: HookRunner, hookAggregator: HookAggregator, + sessionHooksManager: SessionHooksManager, + messagesProvider?: MessagesProvider, ) { this.config = config; this.hookPlanner = hookPlanner; this.hookRunner = hookRunner; this.hookAggregator = hookAggregator; + this.sessionHooksManager = sessionHooksManager; + this.messagesProvider = messagesProvider; + } + + /** + * Set the messages provider for automatic conversation history passing + */ + setMessagesProvider(provider: MessagesProvider): void { + this.messagesProvider = provider; + } + + /** + * Get the current messages provider + */ + getMessagesProvider(): MessagesProvider | undefined { + return this.messagesProvider; } /** @@ -460,10 +484,26 @@ export class HookEventHandler { signal?: AbortSignal, ): Promise { try { - // Create execution plan + // Create execution plan from registry hooks const plan = this.hookPlanner.createExecutionPlan(eventName, context); - if (!plan || plan.hookConfigs.length === 0) { + // Get session hooks and merge with registry hooks + const sessionId = input.session_id; + const targetName = context?.toolName || ''; + const sessionHooks = sessionId + ? this.sessionHooksManager.getMatchingHooks( + sessionId, + eventName, + targetName, + ) + : []; + + // Merge hook configs from registry plan and session hooks + const registryHookConfigs = plan?.hookConfigs || []; + const sessionHookConfigs = sessionHooks.map((entry) => entry.config); + const allHookConfigs = [...registryHookConfigs, ...sessionHookConfigs]; + + if (allHookConfigs.length === 0) { return { success: true, allOutputs: [], @@ -472,10 +512,25 @@ export class HookEventHandler { }; } + // Determine execution strategy: sequential if any hook requires it + const sequential = + (plan?.sequential ?? false) || + sessionHooks.some((entry) => entry.sequential === true); + + // Build function hook context with messages from provider + const messages = this.messagesProvider?.(); + const functionContext: FunctionHookContext = { + messages, + toolUseID: + 'tool_use_id' in input ? (input.tool_use_id as string) : undefined, + signal, + }; + + const totalHooks = allHookConfigs.length; const onHookStart = (config: HookConfig, index: number) => { const hookName = this.getHookName(config); debugLogger.debug( - `Hook ${hookName} started for event ${eventName} (${index + 1}/${plan.hookConfigs.length})`, + `Hook ${hookName} started for event ${eventName} (${index + 1}/${totalHooks})`, ); }; @@ -486,23 +541,25 @@ export class HookEventHandler { ); }; - // Execute hooks according to the plan's strategy - const results = plan.sequential + // Execute hooks according to the merged strategy + const results = sequential ? await this.hookRunner.executeHooksSequential( - plan.hookConfigs, + allHookConfigs, eventName, input, onHookStart, onHookEnd, signal, + functionContext, ) : await this.hookRunner.executeHooksParallel( - plan.hookConfigs, + allHookConfigs, eventName, input, onHookStart, onHookEnd, signal, + functionContext, ); // Aggregate results @@ -646,7 +703,9 @@ export class HookEventHandler { /** * Get hook type from execution result for telemetry */ - private getHookTypeFromResult(result: HookExecutionResult): 'command' { - return result.hookConfig.type as 'command'; + private getHookTypeFromResult( + result: HookExecutionResult, + ): 'command' | 'http' | 'function' { + return result.hookConfig.type; } } diff --git a/packages/core/src/hooks/hookPlanner.test.ts b/packages/core/src/hooks/hookPlanner.test.ts index ae9334068..0b703b8ef 100644 --- a/packages/core/src/hooks/hookPlanner.test.ts +++ b/packages/core/src/hooks/hookPlanner.test.ts @@ -83,7 +83,7 @@ describe('HookPlanner', () => { }); it('should deduplicate hooks with same config', () => { - const config = { type: HookType.Command, command: 'echo test' }; + const config = { type: HookType.Command as const, command: 'echo test' }; const entry1: HookRegistryEntry = { config, source: HooksConfigSource.Project, diff --git a/packages/core/src/hooks/hookRegistry.test.ts b/packages/core/src/hooks/hookRegistry.test.ts index bcb6481d9..14e340767 100644 --- a/packages/core/src/hooks/hookRegistry.test.ts +++ b/packages/core/src/hooks/hookRegistry.test.ts @@ -26,7 +26,7 @@ describe('HookRegistry', () => { mockConfig = { getProjectRoot: vi.fn().mockReturnValue('/test/project'), isTrustedFolder: vi.fn().mockReturnValue(true), - getHooks: vi.fn().mockReturnValue(undefined), + getUserHooks: vi.fn().mockReturnValue(undefined), getProjectHooks: vi.fn().mockReturnValue(undefined), getExtensions: vi.fn().mockReturnValue([]), }; @@ -57,7 +57,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -65,24 +65,133 @@ describe('HookRegistry', () => { const allHooks = registry.getAllHooks(); expect(allHooks).toHaveLength(1); expect(allHooks[0].eventName).toBe(HookEventName.PreToolUse); - expect(allHooks[0].source).toBe(HooksConfigSource.Project); + expect(allHooks[0].source).toBe(HooksConfigSource.User); }); - it('should not process project hooks in untrusted folder', async () => { + it('should process user hooks even in untrusted folder', async () => { mockConfig.isTrustedFolder = vi.fn().mockReturnValue(false); - const hooksConfig = { + const userHooksConfig = { [HookEventName.PreToolUse]: [ { - hooks: [{ type: HookType.Command, command: 'echo test' }], + hooks: [ + { + type: HookType.Command, + command: 'echo user', + name: 'user-hook', + }, + ], }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooksConfig); + mockConfig.getProjectHooks = vi.fn().mockReturnValue(undefined); const registry = new HookRegistry(mockConfig); await registry.initialize(); - expect(registry.getAllHooks()).toHaveLength(0); + const allHooks = registry.getAllHooks(); + expect(allHooks).toHaveLength(1); + expect(allHooks[0].source).toBe(HooksConfigSource.User); + }); + + it('should load hooks from getUserHooks regardless of trust', async () => { + // In the new design, the CLI filters workspace hooks before passing to core + // So core just loads whatever getUserHooks returns + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + ], + }, + ], + }; + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getProjectHooks = vi.fn().mockReturnValue(undefined); + mockConfig.isTrustedFolder = vi.fn().mockReturnValue(false); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + // Hooks should be loaded because CLI already filtered them + expect(registry.getAllHooks()).toHaveLength(1); + expect(registry.getAllHooks()[0].source).toBe(HooksConfigSource.User); + }); + + it('should load both user and project hooks in trusted folder', async () => { + mockConfig.isTrustedFolder = vi.fn().mockReturnValue(true); + const userHooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo user', + name: 'user-hook', + }, + ], + }, + ], + }; + const projectHooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo project', + name: 'project-hook', + }, + ], + }, + ], + }; + mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooksConfig); + mockConfig.getProjectHooks = vi.fn().mockReturnValue(projectHooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const allHooks = registry.getAllHooks(); + expect(allHooks).toHaveLength(2); + // User hooks should have priority (lower number) over project hooks + expect(allHooks[0].source).toBe(HooksConfigSource.User); + expect(allHooks[0].config.name).toBe('user-hook'); + expect(allHooks[1].source).toBe(HooksConfigSource.Project); + expect(allHooks[1].config.name).toBe('project-hook'); + }); + + it('should not load project hooks in untrusted folder', async () => { + mockConfig.isTrustedFolder = vi.fn().mockReturnValue(false); + const userHooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo user', + name: 'user-hook', + }, + ], + }, + ], + }; + mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooksConfig); + // getProjectHooks should return undefined in untrusted folder + // (this is handled by Config.getProjectHooks() checking trust) + mockConfig.getProjectHooks = vi.fn().mockReturnValue(undefined); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const allHooks = registry.getAllHooks(); + expect(allHooks).toHaveLength(1); + expect(allHooks[0].source).toBe(HooksConfigSource.User); + expect(allHooks[0].config.name).toBe('user-hook'); }); }); @@ -108,7 +217,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -141,7 +250,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -153,29 +262,49 @@ describe('HookRegistry', () => { }); it('should sort hooks by source priority', async () => { - // This test requires multiple sources, which would need getUserHooks - // For now, we test with extensions which are processed after project hooks - const projectHooks = { + // Test with user hooks and extension hooks to verify source priority + const userHooks = { [HookEventName.PreToolUse]: [ { hooks: [ { type: HookType.Command, - command: 'echo project', - name: 'project-hook', + command: 'echo user', + name: 'user-hook', }, ], }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(projectHooks); + mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooks); + mockConfig.getExtensions = vi.fn().mockReturnValue([ + { + isActive: true, + hooks: { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo extension', + name: 'extension-hook', + }, + ], + }, + ], + }, + }, + ]); const registry = new HookRegistry(mockConfig); await registry.initialize(); const hooks = registry.getHooksForEvent(HookEventName.PreToolUse); - expect(hooks).toHaveLength(1); - expect(hooks[0].source).toBe(HooksConfigSource.Project); + // Should have both user and extension hooks + expect(hooks).toHaveLength(2); + // User hooks have higher priority (lower number) than extensions + expect(hooks[0].source).toBe(HooksConfigSource.User); + expect(hooks[1].source).toBe(HooksConfigSource.Extensions); }); }); @@ -194,7 +323,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -223,7 +352,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -258,7 +387,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -296,7 +425,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -312,7 +441,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -320,6 +449,86 @@ describe('HookRegistry', () => { expect(registry.getAllHooks()).toHaveLength(0); }); + it('should discard HTTP hooks without url field', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [{ type: HookType.Http } as HookConfig], + }, + ], + }; + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + }); + + it('should discard function hooks without callback field', async () => { + const hooksConfig = { + [HookEventName.SessionStart]: [ + { + hooks: [{ type: HookType.Function } as HookConfig], + }, + ], + }; + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + }); + + it('should accept valid HTTP hooks with url', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Http, + url: 'http://localhost:8080/hook', + name: 'http-hook', + }, + ], + }, + ], + }; + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(1); + expect(registry.getAllHooks()[0].config.type).toBe(HookType.Http); + }); + + it('should accept valid function hooks with callback', async () => { + const callback = vi.fn(); + const hooksConfig = { + [HookEventName.SessionStart]: [ + { + hooks: [ + { + type: HookType.Function, + callback, + name: 'function-hook', + errorMessage: 'Error occurred', + }, + ], + }, + ], + }; + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(1); + expect(registry.getAllHooks()[0].config.type).toBe(HookType.Function); + }); + it('should skip invalid event names', async () => { const hooksConfig = { InvalidEventName: [ @@ -328,7 +537,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig, mockFeedbackEmitter); await registry.initialize(); @@ -356,7 +565,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -388,7 +597,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -413,7 +622,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -438,7 +647,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -548,7 +757,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -572,7 +781,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); @@ -595,13 +804,15 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); const hooks = registry.getAllHooks(); - expect(hooks[0].config.source).toBe(HooksConfigSource.Project); + expect((hooks[0].config as { source?: unknown }).source).toBe( + HooksConfigSource.User, + ); }); }); @@ -620,7 +831,7 @@ describe('HookRegistry', () => { }, ], }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig); const registry = new HookRegistry(mockConfig); await registry.initialize(); diff --git a/packages/core/src/hooks/hookRegistry.ts b/packages/core/src/hooks/hookRegistry.ts index 37fb76f0c..1ad516f08 100644 --- a/packages/core/src/hooks/hookRegistry.ts +++ b/packages/core/src/hooks/hookRegistry.ts @@ -11,7 +11,6 @@ import { HOOKS_CONFIG_FIELDS, } from './types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; -import { TrustedHooksManager } from './trustedHooks.js'; const debugLogger = createDebugLogger('HOOK_REGISTRY'); @@ -30,7 +29,7 @@ export interface ExtensionWithHooks { export interface HookRegistryConfig { getProjectRoot(): string; isTrustedFolder(): boolean; - getHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined; + getUserHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined; getProjectHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined; getExtensions(): ExtensionWithHooks[]; } @@ -126,63 +125,35 @@ export class HookRegistry { private getHookName( entry: HookRegistryEntry | { config: HookConfig }, ): string { - return entry.config.name || entry.config.command || 'unknown-command'; - } - - /** - * Check for untrusted project hooks and warn the user - */ - private checkProjectHooksTrust(): void { - const projectHooks = this.config.getProjectHooks(); - if (!projectHooks) return; - - try { - const trustedHooksManager = new TrustedHooksManager(); - const untrusted = trustedHooksManager.getUntrustedHooks( - this.config.getProjectRoot(), - projectHooks, - ); - - if (untrusted.length > 0) { - const message = `WARNING: The following project-level hooks have been detected in this workspace: -${untrusted.map((h: string) => ` - ${h}`).join('\n')} - -These hooks will be executed. If you did not configure these hooks or do not trust this project, -please review the project settings (.qwen/settings.json) and remove them.`; - this.feedbackEmitter?.emitFeedback('warning', message); - - // Trust them so we don't warn again - trustedHooksManager.trustHooks( - this.config.getProjectRoot(), - projectHooks, - ); - } - } catch { - debugLogger.warn('Failed to check project hooks trust'); - } + const config = entry.config; + if (config.name) return config.name; + if (config.type === 'command') + return (config as { command?: string }).command || 'unknown-command'; + if (config.type === 'http') + return (config as { url?: string }).url || 'unknown-url'; + if (config.type === 'function') + return (config as { id?: string }).id || 'unknown-function'; + return 'unknown-hook'; } /** * Process hooks from the config that was already loaded by the CLI */ private processHooksFromConfig(): void { - if (this.config.isTrustedFolder()) { - this.checkProjectHooksTrust(); + // Load user hooks (always available, regardless of folder trust) + const userHooks = this.config.getUserHooks(); + if (userHooks) { + this.processHooksConfiguration(userHooks, HooksConfigSource.User); } - // Get hooks from the main config (this comes from the merged settings) - const configHooks = this.config.getHooks(); - if (configHooks) { - if (this.config.isTrustedFolder()) { - this.processHooksConfiguration(configHooks, HooksConfigSource.Project); - } else { - debugLogger.warn( - 'Project hooks disabled because the folder is not trusted.', - ); - } + // Load project hooks (only in trusted folders) + // The config.getProjectHooks() already checks trust status internally + const projectHooks = this.config.getProjectHooks(); + if (projectHooks) { + this.processHooksConfiguration(projectHooks, HooksConfigSource.Project); } - // Get hooks from extensions + // Extension hooks are always loaded const extensions = this.config.getExtensions() || []; for (const extension of extensions) { if (extension.isActive && extension.hooks) { @@ -273,8 +244,10 @@ please review the project settings (.qwen/settings.json) and remove them.`; continue; } - // Add source to hook config - hookConfig.source = source; + // Add source to hook config (only for command and http hooks) + if (hookConfig.type !== 'function') { + (hookConfig as { source?: HooksConfigSource }).source = source; + } this.entries.push({ config: hookConfig, @@ -302,7 +275,10 @@ please review the project settings (.qwen/settings.json) and remove them.`; eventName: HookEventName, source: HooksConfigSource, ): boolean { - if (!config.type || !['command', 'plugin'].includes(config.type)) { + if ( + !config.type || + !['command', 'http', 'function'].includes(config.type) + ) { debugLogger.warn( `Invalid hook ${eventName} from ${source} type: ${config.type}`, ); @@ -316,6 +292,20 @@ please review the project settings (.qwen/settings.json) and remove them.`; return false; } + if (config.type === 'http' && !config.url) { + debugLogger.warn( + `HTTP hook ${eventName} from ${source} missing url field`, + ); + return false; + } + + if (config.type === 'function' && typeof config.callback !== 'function') { + debugLogger.warn( + `Function hook ${eventName} from ${source} missing or invalid callback`, + ); + return false; + } + return true; } diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index 7b50a031d..57245bed7 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -740,4 +740,73 @@ describe('HookRunner', () => { expect(result.output?.decision).toBe('allow'); }); }); + + describe('shell configuration', () => { + it('should use global shell configuration when hookConfig.shell is not specified', async () => { + const mockProcess = createMockProcess(0, '{"continue": true}'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + // No shell specified - should use global config + }; + const input = createMockInput(); + + await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input); + + // Verify spawn was called with global shell config + expect(mockSpawn).toHaveBeenCalled(); + const spawnArgs = mockSpawn.mock.calls[0]; + // Global config uses bash or cmd depending on platform + expect(spawnArgs[2].shell).toBe(false); + }); + + it('should use bash shell when hookConfig.shell is bash', async () => { + const mockProcess = createMockProcess(0, '{"continue": true}'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + shell: 'bash', + }; + const input = createMockInput(); + + await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input); + + // Verify spawn was called with bash configuration + expect(mockSpawn).toHaveBeenCalled(); + const spawnArgs = mockSpawn.mock.calls[0]; + // Should use bash executable + expect(spawnArgs[0]).toMatch(/bash/); + expect(spawnArgs[1]).toContain('-c'); + expect(spawnArgs[2].shell).toBe(false); + }); + + it('should use powershell when hookConfig.shell is powershell', async () => { + const mockProcess = createMockProcess(0, '{"continue": true}'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'Write-Output test', + source: HooksConfigSource.Project, + shell: 'powershell', + }; + const input = createMockInput(); + + await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input); + + // Verify spawn was called with powershell configuration + expect(mockSpawn).toHaveBeenCalled(); + const spawnArgs = mockSpawn.mock.calls[0]; + // Should use powershell executable + expect(spawnArgs[0]).toBe('powershell'); + expect(spawnArgs[1]).toContain('-Command'); + expect(spawnArgs[2].shell).toBe(false); + }); + }); }); diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index db25e44fe..dc1492f86 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -5,7 +5,7 @@ */ import { spawn } from 'node:child_process'; -import { HookEventName } from './types.js'; +import { HookEventName, HookType } from './types.js'; import type { HookConfig, HookInput, @@ -13,13 +13,19 @@ import type { HookExecutionResult, PreToolUseInput, UserPromptSubmitInput, + CommandHookConfig, + FunctionHookContext, } from './types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { escapeShellArg, getShellConfiguration, type ShellType, + type ShellConfiguration, } from '../utils/shell-utils.js'; +import { HttpHookRunner } from './httpHookRunner.js'; +import { FunctionHookRunner } from './functionHookRunner.js'; +import { AsyncHookRegistry, generateHookId } from './asyncHookRegistry.js'; const debugLogger = createDebugLogger('TRUSTED_HOOKS'); @@ -41,47 +47,116 @@ const EXIT_CODE_SUCCESS = 0; const EXIT_CODE_NON_BLOCKING_ERROR = 1; /** - * Hook runner that executes command hooks + * Hook runner that executes command, HTTP, and function hooks */ export class HookRunner { + private readonly httpRunner: HttpHookRunner; + private readonly functionRunner: FunctionHookRunner; + private readonly asyncRegistry: AsyncHookRegistry; + + constructor(allowedHttpUrls?: string[]) { + this.httpRunner = new HttpHookRunner(allowedHttpUrls); + this.functionRunner = new FunctionHookRunner(); + this.asyncRegistry = new AsyncHookRegistry(); + } + + /** + * Get the async hook registry + */ + getAsyncRegistry(): AsyncHookRegistry { + return this.asyncRegistry; + } + + /** + * Update allowed HTTP URLs + */ + updateAllowedHttpUrls(allowedUrls: string[]): void { + this.httpRunner.updateAllowedUrls(allowedUrls); + } + /** * Execute a single hook * @param hookConfig Hook configuration * @param eventName Event name * @param input Hook input - * @param signal Optional AbortSignal to cancel hook execution + * @param contextOrSignal Optional context (for function hooks) or AbortSignal */ async executeHook( hookConfig: HookConfig, eventName: HookEventName, input: HookInput, - signal?: AbortSignal, + contextOrSignal?: FunctionHookContext | AbortSignal, ): Promise { const startTime = Date.now(); + // Extract signal from context or use directly + const signal = + contextOrSignal && 'aborted' in contextOrSignal + ? contextOrSignal + : contextOrSignal?.signal; + // Check if already aborted before starting if (signal?.aborted) { - const hookId = hookConfig.name || hookConfig.command || 'unknown'; + const hookId = this.getHookId(hookConfig); return { hookConfig, eventName, success: false, + outcome: 'cancelled', error: new Error(`Hook execution cancelled (aborted): ${hookId}`), duration: 0, }; } try { - return await this.executeCommandHook( - hookConfig, - eventName, - input, - startTime, - signal, - ); + // Check if this is an async command hook + if (this.isAsyncHook(hookConfig)) { + return this.executeAsyncHook( + hookConfig as CommandHookConfig, + eventName, + input, + signal, + ); + } + + // Route to appropriate runner based on hook type + switch (hookConfig.type) { + case HookType.Command: + return await this.executeCommandHook( + hookConfig, + eventName, + input, + startTime, + signal, + ); + case HookType.Http: + return await this.httpRunner.execute( + hookConfig, + eventName, + input, + signal, + ); + case HookType.Function: { + // Function hooks accept context, not just signal + const functionContext = + contextOrSignal && !('aborted' in contextOrSignal) + ? contextOrSignal + : { signal }; + return await this.functionRunner.execute( + hookConfig, + eventName, + input, + functionContext, + ); + } + default: + throw new Error( + `Unknown hook type: ${(hookConfig as HookConfig).type}`, + ); + } } catch (error) { const duration = Date.now() - startTime; - const hookId = hookConfig.name || hookConfig.command || 'unknown'; + const hookId = this.getHookId(hookConfig); const errorMessage = `Hook execution failed for event '${eventName}' (hook: ${hookId}): ${error}`; debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`); @@ -95,9 +170,219 @@ export class HookRunner { } } + /** + * Check if a hook should be executed asynchronously + */ + private isAsyncHook(hookConfig: HookConfig): boolean { + return hookConfig.type === HookType.Command && hookConfig.async === true; + } + + /** + * Get a unique identifier for a hook + */ + private getHookId(hookConfig: HookConfig): string { + if (hookConfig.name) { + return hookConfig.name; + } + switch (hookConfig.type) { + case HookType.Command: + return hookConfig.command || 'unknown-command'; + case HookType.Http: + return hookConfig.url || 'unknown-url'; + case HookType.Function: + return hookConfig.id || 'unknown-function'; + default: + return 'unknown'; + } + } + + /** + * Get shell configuration for a hook, respecting hookConfig.shell override + */ + private getShellConfigForHook( + hookConfig: CommandHookConfig, + ): ShellConfiguration { + const globalConfig = getShellConfiguration(); + + // If hook specifies a shell, use it + if (hookConfig.shell) { + const shellType: ShellType = + hookConfig.shell === 'powershell' ? 'powershell' : 'bash'; + + // Return configuration for the specified shell type + if (shellType === 'powershell') { + return { + shell: 'powershell', + executable: 'powershell', + argsPrefix: ['-Command'], + }; + } + + // For bash, use global config's executable path or fallback + return { + shell: 'bash', + executable: + globalConfig.shell === 'bash' ? globalConfig.executable : 'bash', + argsPrefix: ['-c'], + }; + } + + // Use global configuration + return globalConfig; + } + + /** + * Execute a command hook asynchronously (non-blocking) + */ + private async executeAsyncHook( + hookConfig: CommandHookConfig, + eventName: HookEventName, + input: HookInput, + signal?: AbortSignal, + ): Promise { + const hookId = generateHookId(); + const hookName = hookConfig.name || hookConfig.command || 'async-hook'; + + // Check concurrency limit before registering + if (!this.asyncRegistry.canAcceptMore()) { + debugLogger.warn( + `Async hook rejected due to concurrency limit: ${hookName}`, + ); + return { + hookConfig, + eventName, + success: false, + duration: 0, + isAsync: true, + error: new Error( + 'Async hook rejected: too many concurrent async hooks running', + ), + output: { continue: true }, // Non-blocking, continue execution + }; + } + + // Register in async registry + const registeredId = this.asyncRegistry.register({ + hookId, + hookName, + hookEvent: eventName, + sessionId: input.session_id, + startTime: Date.now(), + timeout: hookConfig.timeout || DEFAULT_HOOK_TIMEOUT, + stdout: '', + stderr: '', + }); + + // Double-check registration succeeded (race condition protection) + if (!registeredId) { + debugLogger.warn( + `Async hook registration failed due to concurrency limit: ${hookName}`, + ); + return { + hookConfig, + eventName, + success: false, + duration: 0, + isAsync: true, + error: new Error( + 'Async hook rejected: too many concurrent async hooks running', + ), + output: { continue: true }, + }; + } + + // Execute in background with proper error handling + this.executeCommandHookInBackground( + hookConfig, + eventName, + input, + hookId, + signal, + ).catch((error) => { + // This catch handles any unexpected errors that escape the try-catch in executeCommandHookInBackground + debugLogger.error( + `Unexpected error in async hook background execution: ${hookId} (${hookName}): ${error instanceof Error ? error.message : String(error)}`, + ); + // Ensure the hook is marked as failed in the registry + try { + this.asyncRegistry.fail( + hookId, + error instanceof Error + ? error + : new Error(`Unexpected error: ${String(error)}`), + ); + } catch (registryError) { + // Registry operation failed, log but don't throw + debugLogger.error( + `Failed to update async registry for hook ${hookId}: ${registryError}`, + ); + } + }); + + // Return immediately with success + debugLogger.debug(`Started async hook: ${hookId} (${hookName})`); + return { + hookConfig, + eventName, + success: true, + duration: 0, + isAsync: true, + output: { continue: true }, + }; + } + + /** + * Execute a command hook in the background + */ + private async executeCommandHookInBackground( + hookConfig: CommandHookConfig, + eventName: HookEventName, + input: HookInput, + hookId: string, + signal?: AbortSignal, + ): Promise { + const hookName = hookConfig.name || hookConfig.command || 'async-hook'; + + try { + debugLogger.debug(`Executing async hook in background: ${hookId}`); + + const result = await this.executeCommandHook( + hookConfig, + eventName, + input, + Date.now(), + signal, + ); + + // Update registry with result + if (result.success) { + this.asyncRegistry.updateOutput(hookId, result.stdout, result.stderr); + this.asyncRegistry.complete(hookId, result.output); + debugLogger.debug( + `Async hook completed successfully: ${hookId} (${hookName})`, + ); + } else { + const error = result.error || new Error('Unknown error'); + this.asyncRegistry.fail(hookId, error); + debugLogger.warn( + `Async hook failed: ${hookId} (${hookName}): ${error.message}`, + ); + } + } catch (error) { + const errorObj = + error instanceof Error ? error : new Error(String(error)); + this.asyncRegistry.fail(hookId, errorObj); + debugLogger.error( + `Async hook threw exception: ${hookId} (${hookName}): ${errorObj.message}`, + ); + // Re-throw to be caught by the .catch() in executeAsyncHook + throw error; + } + } + /** * Execute multiple hooks in parallel - * @param signal Optional AbortSignal to cancel hook execution + * @param context Optional function hook context (messages, toolUseID) */ async executeHooksParallel( hookConfigs: HookConfig[], @@ -106,10 +391,14 @@ export class HookRunner { onHookStart?: (config: HookConfig, index: number) => void, onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void, signal?: AbortSignal, + context?: FunctionHookContext, ): Promise { const promises = hookConfigs.map(async (config, index) => { onHookStart?.(config, index); - const result = await this.executeHook(config, eventName, input, signal); + const result = await this.executeHook(config, eventName, input, { + ...context, + signal, + }); onHookEnd?.(config, result); return result; }); @@ -119,7 +408,7 @@ export class HookRunner { /** * Execute multiple hooks sequentially - * @param signal Optional AbortSignal to cancel hook execution + * @param context Optional function hook context (messages, toolUseID) */ async executeHooksSequential( hookConfigs: HookConfig[], @@ -128,6 +417,7 @@ export class HookRunner { onHookStart?: (config: HookConfig, index: number) => void, onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void, signal?: AbortSignal, + context?: FunctionHookContext, ): Promise { const results: HookExecutionResult[] = []; let currentInput = input; @@ -139,12 +429,10 @@ export class HookRunner { } const config = hookConfigs[i]; onHookStart?.(config, i); - const result = await this.executeHook( - config, - eventName, - currentInput, + const result = await this.executeHook(config, eventName, currentInput, { + ...context, signal, - ); + }); onHookEnd?.(config, result); results.push(result); @@ -222,7 +510,7 @@ export class HookRunner { * @param signal Optional AbortSignal to cancel hook execution */ private async executeCommandHook( - hookConfig: HookConfig, + hookConfig: CommandHookConfig, eventName: HookEventName, input: HookInput, startTime: number, @@ -251,7 +539,8 @@ export class HookRunner { let timedOut = false; let aborted = false; - const shellConfig = getShellConfiguration(); + // Use hook-specific shell configuration if specified + const shellConfig = this.getShellConfigForHook(hookConfig); const command = this.expandCommand( hookConfig.command, input, diff --git a/packages/core/src/hooks/hookSystem.test.ts b/packages/core/src/hooks/hookSystem.test.ts index 79a0fe357..ae087c5cb 100644 --- a/packages/core/src/hooks/hookSystem.test.ts +++ b/packages/core/src/hooks/hookSystem.test.ts @@ -11,6 +11,7 @@ import { HookRunner } from './hookRunner.js'; import { HookAggregator } from './hookAggregator.js'; import { HookPlanner } from './hookPlanner.js'; import { HookEventHandler } from './hookEventHandler.js'; +import { SessionHooksManager } from './sessionHooksManager.js'; import { HookType, HooksConfigSource, @@ -59,6 +60,7 @@ describe('HookSystem', () => { getSessionId: vi.fn().mockReturnValue('test-session-id'), getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'), getWorkingDir: vi.fn().mockReturnValue('/test/cwd'), + getAllowedHttpHookUrls: vi.fn().mockReturnValue([]), } as unknown as Config; mockHookRegistry = { @@ -94,6 +96,7 @@ describe('HookSystem', () => { firePermissionRequestEvent: vi.fn(), fireSubagentStartEvent: vi.fn(), fireSubagentStopEvent: vi.fn(), + setMessagesProvider: vi.fn(), } as unknown as HookEventHandler; vi.mocked(HookRegistry).mockImplementation(() => mockHookRegistry); @@ -116,6 +119,7 @@ describe('HookSystem', () => { mockHookPlanner, mockHookRunner, mockHookAggregator, + expect.any(SessionHooksManager), ); }); }); @@ -169,7 +173,7 @@ describe('HookSystem', () => { const mockHooks = [ { config: { - type: HookType.Command, + type: HookType.Command as const, command: 'echo test', source: HooksConfigSource.Project, }, @@ -1662,4 +1666,23 @@ describe('HookSystem', () => { expect(result?.isBlockingDecision()).toBe(false); }); }); + + describe('MessagesProvider', () => { + it('should set messagesProvider and forward to eventHandler', () => { + const provider = vi + .fn() + .mockReturnValue([{ role: 'user', content: 'test' }]); + + hookSystem.setMessagesProvider(provider); + + expect(mockHookEventHandler.setMessagesProvider).toHaveBeenCalledWith( + provider, + ); + expect(hookSystem.getMessagesProvider()).toBe(provider); + }); + + it('should return undefined when no provider is set', () => { + expect(hookSystem.getMessagesProvider()).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/hooks/hookSystem.ts b/packages/core/src/hooks/hookSystem.ts index 2408d446f..3b5ab51c5 100644 --- a/packages/core/src/hooks/hookSystem.ts +++ b/packages/core/src/hooks/hookSystem.ts @@ -24,8 +24,19 @@ import type { NotificationType, PermissionSuggestion, HookEventName, + FunctionHookCallback, + CommandHookConfig, + HttpHookConfig, + PendingAsyncHook, + PendingAsyncOutput, + MessagesProvider, StopFailureErrorType, } from './types.js'; +import { SessionHooksManager } from './sessionHooksManager.js'; +import type { AsyncHookRegistry } from './asyncHookRegistry.js'; + +// Re-export MessagesProvider for external use +export type { MessagesProvider } from './types.js'; const debugLogger = createDebugLogger('TRUSTED_HOOKS'); @@ -39,18 +50,26 @@ export class HookSystem { private readonly hookAggregator: HookAggregator; private readonly hookPlanner: HookPlanner; private readonly hookEventHandler: HookEventHandler; + private readonly sessionHooksManager: SessionHooksManager; + /** Optional provider for automatically fetching conversation history */ + private messagesProvider?: MessagesProvider; constructor(config: Config) { + // Get allowed HTTP URLs from config + const allowedHttpUrls = config.getAllowedHttpHookUrls(); + // Initialize components this.hookRegistry = new HookRegistry(config); - this.hookRunner = new HookRunner(); + this.hookRunner = new HookRunner(allowedHttpUrls); this.hookAggregator = new HookAggregator(); this.hookPlanner = new HookPlanner(this.hookRegistry); + this.sessionHooksManager = new SessionHooksManager(); this.hookEventHandler = new HookEventHandler( config, this.hookPlanner, this.hookRunner, this.hookAggregator, + this.sessionHooksManager, ); } @@ -62,6 +81,22 @@ export class HookSystem { debugLogger.debug('Hook system initialized successfully'); } + /** + * Set the messages provider for automatic conversation history passing + * to function hooks during execution + */ + setMessagesProvider(provider: MessagesProvider): void { + this.messagesProvider = provider; + this.hookEventHandler.setMessagesProvider(provider); + } + + /** + * Get the current messages provider + */ + getMessagesProvider(): MessagesProvider | undefined { + return this.messagesProvider; + } + /** * Get the hook event bus for firing events */ @@ -371,4 +406,179 @@ export class HookSystem { ? createHookOutput('PermissionRequest', result.finalOutput) : undefined; } + + // ==================== Session Hooks API ==================== + + /** + * Add a function hook for a session + * @param sessionId Session ID + * @param event Hook event name + * @param matcher Matcher pattern (e.g., 'Bash', '*', 'Write|Edit', or regex) + * @param callback Function callback to execute + * @param errorMessage Error message to display on failure + * @param options Additional options + * @returns Hook ID for later removal + */ + addFunctionHook( + sessionId: string, + event: HookEventName, + matcher: string, + callback: FunctionHookCallback, + errorMessage: string, + options?: { + timeout?: number; + id?: string; + name?: string; + description?: string; + statusMessage?: string; + skillRoot?: string; + }, + ): string { + return this.sessionHooksManager.addFunctionHook( + sessionId, + event, + matcher, + callback, + errorMessage, + options, + ); + } + + /** + * Add a command or HTTP hook for a session + * @param sessionId Session ID + * @param event Hook event name + * @param matcher Matcher pattern + * @param hook Hook configuration (command or HTTP) + * @param options Additional options + * @returns Hook ID + */ + addSessionHook( + sessionId: string, + event: HookEventName, + matcher: string, + hook: CommandHookConfig | HttpHookConfig, + options?: { sequential?: boolean }, + ): string { + return this.sessionHooksManager.addSessionHook( + sessionId, + event, + matcher, + hook, + options, + ); + } + + /** + * Remove a function hook by ID + * @param sessionId Session ID + * @param event Hook event name + * @param hookId Hook ID to remove + * @returns True if hook was found and removed + */ + removeFunctionHook( + sessionId: string, + event: HookEventName, + hookId: string, + ): boolean { + return this.sessionHooksManager.removeFunctionHook( + sessionId, + event, + hookId, + ); + } + + /** + * Remove a hook by ID (searches all events) + * @param sessionId Session ID + * @param hookId Hook ID to remove + * @returns True if hook was found and removed + */ + removeSessionHook(sessionId: string, hookId: string): boolean { + return this.sessionHooksManager.removeHook(sessionId, hookId); + } + + /** + * Check if a session has any hooks registered + * @param sessionId Session ID + * @returns True if session has hooks + */ + hasSessionHooks(sessionId: string): boolean { + return this.sessionHooksManager.hasSessionHooks(sessionId); + } + + /** + * Clear all hooks for a session + * @param sessionId Session ID + */ + clearSessionHooks(sessionId: string): void { + this.sessionHooksManager.clearSessionHooks(sessionId); + // Also clear async hooks for this session + this.getAsyncRegistry().clearSession(sessionId); + } + + /** + * Get the session hooks manager + */ + getSessionHooksManager(): SessionHooksManager { + return this.sessionHooksManager; + } + + // ==================== Async Hooks API ==================== + + /** + * Get the async hook registry + */ + getAsyncRegistry(): AsyncHookRegistry { + return this.hookRunner.getAsyncRegistry(); + } + + /** + * Get all pending async hooks + */ + getPendingAsyncHooks(): PendingAsyncHook[] { + return this.getAsyncRegistry().getPendingHooks(); + } + + /** + * Get pending async hooks for a specific session + */ + getPendingAsyncHooksForSession(sessionId: string): PendingAsyncHook[] { + return this.getAsyncRegistry().getPendingHooksForSession(sessionId); + } + + /** + * Get and clear pending async output for delivery to the next turn + */ + getPendingAsyncOutput(): PendingAsyncOutput { + return this.getAsyncRegistry().getPendingOutput(); + } + + /** + * Check if there are any pending async outputs + */ + hasPendingAsyncOutput(): boolean { + return this.getAsyncRegistry().hasPendingOutput(); + } + + /** + * Check if there are any running async hooks + */ + hasRunningAsyncHooks(): boolean { + return this.getAsyncRegistry().hasRunningHooks(); + } + + /** + * Check for timed out async hooks and mark them + */ + checkAsyncHookTimeouts(): void { + this.getAsyncRegistry().checkTimeouts(); + } + + /** + * Update allowed HTTP hook URLs + */ + updateAllowedHttpUrls(allowedUrls: string[]): void { + this.hookRunner.updateAllowedHttpUrls(allowedUrls); + } } diff --git a/packages/core/src/hooks/httpHookRunner.test.ts b/packages/core/src/hooks/httpHookRunner.test.ts new file mode 100644 index 000000000..f60528fbf --- /dev/null +++ b/packages/core/src/hooks/httpHookRunner.test.ts @@ -0,0 +1,292 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { HookEventName, HookType } from './types.js'; +import type { HttpHookConfig, HookInput } from './types.js'; +import { HttpHookRunner } from './httpHookRunner.js'; + +// Mock fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Mock dns.lookup to avoid real DNS lookups in tests +vi.mock('dns', () => ({ + lookup: ( + _hostname: string, + _options: object, + callback: ( + err: null, + addresses: Array<{ address: string; family: number }>, + ) => void, + ) => { + // Return a mock public IP address + callback(null, [{ address: '8.8.8.8', family: 4 }]); + }, +})); + +describe('HttpHookRunner', () => { + let httpRunner: HttpHookRunner; + const originalEnv = process.env; + // Use escaped dots in URL patterns to satisfy CodeQL security scanning + // The UrlValidator.compilePattern method also escapes dots, but we use + // pre-escaped patterns here to make the security intent explicit + const ALLOWED_URL_PATTERN = 'https://api\\.example\\.com/*'; + + beforeEach(() => { + httpRunner = new HttpHookRunner([ALLOWED_URL_PATTERN]); + vi.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + const createMockInput = (overrides: Partial = {}): HookInput => ({ + session_id: 'test-session', + transcript_path: '/test/transcript', + cwd: '/test', + hook_event_name: 'PreToolUse', + timestamp: '2024-01-01T00:00:00Z', + ...overrides, + }); + + const createMockConfig = ( + overrides: Partial = {}, + ): HttpHookConfig => ({ + type: HookType.Http, + url: 'https://api.example.com/hook', + ...overrides, + }); + + describe('execute', () => { + it('should fail for URL not in whitelist', async () => { + const config = createMockConfig({ + url: 'https://other.com/hook', + }); + const input = createMockInput(); + + const result = await httpRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.error?.message).toContain('URL validation failed'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should fail for blocked URL (SSRF - link-local metadata)', async () => { + const runner = new HttpHookRunner([]); // Allow all patterns + const config = createMockConfig({ + url: 'http://169.254.169.254/latest/meta-data', + }); + const input = createMockInput(); + + const result = await runner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.error?.message).toContain('blocked'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should ALLOW localhost for local dev hooks', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ continue: true }), + }); + + const runner = new HttpHookRunner([]); // Allow all patterns + const config = createMockConfig({ + url: 'http://localhost:8080/hook', + }); + const input = createMockInput(); + + const result = await runner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('should interpolate environment variables in headers', async () => { + process.env['MY_TOKEN'] = 'secret-token'; + + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ continue: true }), + }); + + const config = createMockConfig({ + headers: { Authorization: 'Bearer $MY_TOKEN' }, + allowedEnvVars: ['MY_TOKEN'], + }); + const input = createMockInput(); + + await httpRunner.execute(config, HookEventName.PreToolUse, input); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer secret-token', + }), + }), + ); + }); + + it('should handle HTTP error response as non-blocking error', async () => { + // Per Claude Code spec: Non-2xx status is a non-blocking error + // Execution continues with success: true + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + const config = createMockConfig(); + const input = createMockInput(); + + const result = await httpRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + // Non-2xx is a non-blocking error, so success should be true + expect(result.success).toBe(true); + expect(result.output?.continue).toBe(true); + }); + + it('should handle timeout as non-blocking error', async () => { + // Per Claude Code spec: Timeout is a non-blocking error + // Execution continues with success: true + mockFetch.mockImplementationOnce( + () => + new Promise((_, reject) => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + setTimeout(() => reject(error), 10); + }), + ); + + const config = createMockConfig({ timeout: 1 }); + const input = createMockInput(); + + const result = await httpRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + // Timeout is a non-blocking error, so success should be true + expect(result.success).toBe(true); + expect(result.output?.continue).toBe(true); + }); + + it('should skip once hook on second execution', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ continue: true }), + }); + + const config = createMockConfig({ once: true }); + const input = createMockInput(); + + // First execution + await httpRunner.execute(config, HookEventName.PreToolUse, input); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Second execution - should skip + const result = await httpRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); // Still 1 + }); + + it('should parse JSON response with hook output', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + decision: 'deny', + reason: 'Blocked by policy', + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + }, + }), + }); + + const config = createMockConfig(); + const input = createMockInput(); + + const result = await httpRunner.execute( + config, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.output?.decision).toBe('deny'); + expect(result.output?.reason).toBe('Blocked by policy'); + }); + + it('should handle aborted signal', async () => { + const controller = new AbortController(); + controller.abort(); + + const config = createMockConfig(); + const input = createMockInput(); + + const result = await httpRunner.execute( + config, + HookEventName.PreToolUse, + input, + controller.signal, + ); + + expect(result.success).toBe(false); + expect(result.error?.message).toContain('cancelled'); + }); + }); + + describe('resetOnceHooks', () => { + it('should allow once hooks to execute again after reset', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ continue: true }), + }); + + const config = createMockConfig({ once: true }); + const input = createMockInput(); + + await httpRunner.execute(config, HookEventName.PreToolUse, input); + expect(mockFetch).toHaveBeenCalledTimes(1); + + httpRunner.resetOnceHooks(); + + await httpRunner.execute(config, HookEventName.PreToolUse, input); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/core/src/hooks/httpHookRunner.ts b/packages/core/src/hooks/httpHookRunner.ts new file mode 100644 index 000000000..aad909ed3 --- /dev/null +++ b/packages/core/src/hooks/httpHookRunner.ts @@ -0,0 +1,426 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger } from '../utils/debugLogger.js'; +import { interpolateHeaders, interpolateUrl } from './envInterpolator.js'; +import { UrlValidator } from './urlValidator.js'; +import { createCombinedAbortSignal } from './combinedAbortSignal.js'; +import { isBlockedAddress } from './ssrfGuard.js'; +import { lookup as dnsLookup } from 'dns'; +import type { + HttpHookConfig, + HookInput, + HookOutput, + HookExecutionResult, + HookEventName, +} from './types.js'; + +const debugLogger = createDebugLogger('HTTP_HOOK_RUNNER'); + +/** + * Default timeout for HTTP hook execution + */ +const DEFAULT_HTTP_TIMEOUT = 10 * 60 * 1000; + +/** + * Maximum output length (10,000 characters as per Qwen Code spec) + */ +const MAX_OUTPUT_LENGTH = 10000; + +/** + * Callback for displaying status messages during hook execution + */ +export type StatusMessageCallback = (message: string) => void; + +/** + * Resolve a hostname and validate that all resolved IPs are not in blocked + * ranges. This is the core of DNS-level SSRF protection, aligned with + * + * NOTE: Node.js native `fetch` does not support a custom `lookup` option + * (unlike axios). We validate resolved IPs immediately before the fetch + * call to minimize the rebinding window. + */ +async function validateResolvedHost( + hostname: string, +): Promise<{ ok: boolean; error?: string }> { + return new Promise((resolve) => { + // If hostname is already an IP literal, validate directly. + if (isBlockedAddress(hostname)) { + resolve({ + ok: false, + error: `HTTP hook blocked: ${hostname} is in a private/link-local range`, + }); + return; + } + + // For hostnames, resolve DNS and validate all returned IPs. + dnsLookup(hostname, { all: true }, (err, addresses) => { + if (err) { + // DNS resolution failure — let the fetch call handle it. + resolve({ ok: true }); + return; + } + + for (const addr of addresses) { + if (isBlockedAddress(addr.address)) { + resolve({ + ok: false, + error: `HTTP hook blocked: ${hostname} resolves to ${addr.address} (private/link-local). Loopback (127.0.0.1, ::1) is allowed.`, + }); + return; + } + } + + resolve({ ok: true }); + }); + }); +} + +/** + * HTTP Hook Runner - executes HTTP hooks by sending POST requests + */ +export class HttpHookRunner { + private urlValidator: UrlValidator; + private readonly executedOnceHooks: Set = new Set(); + private statusMessageCallback?: StatusMessageCallback; + + constructor(allowedUrls?: string[]) { + this.urlValidator = new UrlValidator(allowedUrls); + } + + /** + * Set callback for displaying status messages + */ + setStatusMessageCallback(callback: StatusMessageCallback): void { + this.statusMessageCallback = callback; + } + + /** + * Execute an HTTP hook + * @param hookConfig HTTP hook configuration + * @param eventName Event name + * @param input Hook input + * @param signal Optional AbortSignal to cancel hook execution + */ + async execute( + hookConfig: HttpHookConfig, + eventName: HookEventName, + input: HookInput, + signal?: AbortSignal, + ): Promise { + const startTime = Date.now(); + const hookId = hookConfig.name || hookConfig.url; + + // Check if already aborted + if (signal?.aborted) { + return { + hookConfig, + eventName, + success: false, + error: new Error(`HTTP hook execution cancelled (aborted): ${hookId}`), + duration: 0, + }; + } + + // Check once flag + if (hookConfig.once) { + const onceKey = `${hookConfig.url}:${eventName}`; + if (this.executedOnceHooks.has(onceKey)) { + debugLogger.debug( + `Skipping once hook ${hookId} - already executed for ${eventName}`, + ); + return { + hookConfig, + eventName, + success: true, + duration: 0, + output: { continue: true }, + }; + } + this.executedOnceHooks.add(onceKey); + } + + // Display status message if configured + if (hookConfig.statusMessage && this.statusMessageCallback) { + this.statusMessageCallback(hookConfig.statusMessage); + } + + try { + // Interpolate URL with allowed env vars + const url = interpolateUrl( + hookConfig.url, + hookConfig.allowedEnvVars || [], + ); + + // Validate URL format and whitelist (URL-level check) + const validation = this.urlValidator.validate(url); + if (!validation.allowed) { + return { + hookConfig, + eventName, + success: false, + error: new Error(`URL validation failed: ${validation.reason}`), + duration: Date.now() - startTime, + }; + } + + // DNS-level SSRF protection: validate resolved IPs + // It checks that the hostname resolves to non-private IPs. + const parsed = new URL(url); + const hostValidation = await validateResolvedHost(parsed.hostname); + if (!hostValidation.ok) { + return { + hookConfig, + eventName, + success: false, + error: new Error(hostValidation.error), + duration: Date.now() - startTime, + }; + } + + // Interpolate headers with allowed env vars + const headers = hookConfig.headers + ? interpolateHeaders( + hookConfig.headers, + hookConfig.allowedEnvVars || [], + ) + : {}; + + // Prepare request body + const body = JSON.stringify({ + ...input, + hook_event_name: eventName, + }); + + // Set up combined abort signal (external signal + timeout) + const timeout = hookConfig.timeout + ? hookConfig.timeout * 1000 + : DEFAULT_HTTP_TIMEOUT; + const { signal: combinedSignal, cleanup } = createCombinedAbortSignal( + signal, + { timeoutMs: timeout }, + ); + + try { + debugLogger.debug(`Executing HTTP hook: ${hookId} -> ${url}`); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body, + signal: combinedSignal, + }); + + cleanup(); + + const duration = Date.now() - startTime; + + // Per Qwen Code spec: Non-2xx status is a non-blocking error + // Execution continues, but we log a warning + if (!response.ok) { + debugLogger.warn( + `HTTP hook ${hookId} returned non-2xx status ${response.status} (non-blocking)`, + ); + // Return success: true with continue: true for non-blocking error + return { + hookConfig, + eventName, + success: true, + output: { continue: true }, + duration, + }; + } + + // Parse response + const output = await this.parseResponse(response, eventName); + + debugLogger.debug( + `HTTP hook ${hookId} completed successfully in ${duration}ms`, + ); + + return { + hookConfig, + eventName, + success: true, + output, + duration, + }; + } catch (fetchError) { + cleanup(); + + const duration = Date.now() - startTime; + + if ( + fetchError instanceof Error && + (fetchError.name === 'AbortError' || combinedSignal.aborted) + ) { + // Timeout or abort is a non-blocking error per Qwen Code spec + debugLogger.warn( + `HTTP hook ${hookId} timed out or was aborted after ${timeout}ms (non-blocking)`, + ); + return { + hookConfig, + eventName, + success: true, + output: { continue: true }, + duration, + }; + } + + // Connection failure is a non-blocking error per Qwen Code spec + debugLogger.warn( + `HTTP hook ${hookId} connection failed (non-blocking): ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`, + ); + return { + hookConfig, + eventName, + success: true, + output: { continue: true }, + duration, + }; + } + } catch (error) { + const duration = Date.now() - startTime; + const errorMessage = + error instanceof Error ? error.message : String(error); + + debugLogger.warn(`HTTP hook ${hookId} failed: ${errorMessage}`); + + return { + hookConfig, + eventName, + success: false, + error: error instanceof Error ? error : new Error(errorMessage), + duration, + }; + } + } + + /** + * Parse HTTP response into HookOutput + */ + private async parseResponse( + response: Response, + eventName: HookEventName, + ): Promise { + const contentType = response.headers.get('content-type') || ''; + + // Try to parse as JSON + if (contentType.includes('application/json')) { + try { + const json = await response.json(); + return this.normalizeOutput(json, eventName); + } catch { + debugLogger.warn('Failed to parse JSON response, using empty output'); + return { continue: true }; + } + } + + // For plain text responses, add as context (truncated if needed) + const text = await response.text(); + if (text.trim()) { + return { + continue: true, + systemMessage: this.truncateOutput(text.trim()), + }; + } + + // For empty responses, return success with continue + return { continue: true }; + } + + /** + * Truncate output to MAX_OUTPUT_LENGTH characters + * Per Qwen Code spec: output is capped at 10,000 characters + */ + private truncateOutput(output: string): string { + if (output.length <= MAX_OUTPUT_LENGTH) { + return output; + } + const truncated = output.substring(0, MAX_OUTPUT_LENGTH); + debugLogger.debug( + `Output truncated from ${output.length} to ${MAX_OUTPUT_LENGTH} characters`, + ); + return `${truncated}\n... [truncated, ${output.length - MAX_OUTPUT_LENGTH} more characters]`; + } + + /** + * Normalize response JSON into HookOutput format + */ + private normalizeOutput( + json: Record, + eventName: HookEventName, + ): HookOutput { + const output: HookOutput = {}; + + // Map standard fields + if ('continue' in json && typeof json['continue'] === 'boolean') { + output.continue = json['continue']; + } + if ('stopReason' in json && typeof json['stopReason'] === 'string') { + output.stopReason = this.truncateOutput(json['stopReason']); + } + if ( + 'suppressOutput' in json && + typeof json['suppressOutput'] === 'boolean' + ) { + output.suppressOutput = json['suppressOutput']; + } + if ('systemMessage' in json && typeof json['systemMessage'] === 'string') { + // Apply output length limit per Qwen Code spec + output.systemMessage = this.truncateOutput(json['systemMessage']); + } + if ('decision' in json && typeof json['decision'] === 'string') { + output.decision = json['decision'] as HookOutput['decision']; + } + if ('reason' in json && typeof json['reason'] === 'string') { + output.reason = this.truncateOutput(json['reason']); + } + + // Handle hookSpecificOutput + if ( + 'hookSpecificOutput' in json && + typeof json['hookSpecificOutput'] === 'object' && + json['hookSpecificOutput'] !== null + ) { + const hookOutput = json['hookSpecificOutput'] as Record; + // Truncate additionalContext if present + if ( + 'additionalContext' in hookOutput && + typeof hookOutput['additionalContext'] === 'string' + ) { + hookOutput['additionalContext'] = this.truncateOutput( + hookOutput['additionalContext'], + ); + } + output.hookSpecificOutput = hookOutput; + // Ensure hookEventName is set + if (!('hookEventName' in output.hookSpecificOutput)) { + output.hookSpecificOutput['hookEventName'] = eventName; + } + } + + return output; + } + + /** + * Reset once hooks tracking (useful for testing) + */ + resetOnceHooks(): void { + this.executedOnceHooks.clear(); + } + + /** + * Update allowed URLs + */ + updateAllowedUrls(allowedUrls: string[]): void { + // Create new validator with updated patterns + this.urlValidator = new UrlValidator(allowedUrls); + } +} diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index 779f3b332..5f7607dbb 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -15,6 +15,29 @@ export { HookAggregator } from './hookAggregator.js'; export { HookPlanner } from './hookPlanner.js'; export { HookEventHandler } from './hookEventHandler.js'; +// Export new hook runners +export { HttpHookRunner } from './httpHookRunner.js'; +export { FunctionHookRunner } from './functionHookRunner.js'; + +// Export session and async hook management +export { SessionHooksManager } from './sessionHooksManager.js'; +export type { SessionHookEntry } from './sessionHooksManager.js'; +export { AsyncHookRegistry, generateHookId } from './asyncHookRegistry.js'; +export { + registerSkillHooks, + unregisterSkillHooks, +} from './registerSkillHooks.js'; + +// Export utilities +export { + interpolateEnvVars, + interpolateHeaders, + interpolateUrl, + hasEnvVarReferences, + extractEnvVarNames, +} from './envInterpolator.js'; +export { UrlValidator, createUrlValidator } from './urlValidator.js'; + // Export interfaces and enums export type { HookRegistryEntry } from './hookRegistry.js'; export { HooksConfigSource as ConfigSource } from './types.js'; diff --git a/packages/core/src/hooks/registerSkillHooks.test.ts b/packages/core/src/hooks/registerSkillHooks.test.ts new file mode 100644 index 000000000..fdf51e06f --- /dev/null +++ b/packages/core/src/hooks/registerSkillHooks.test.ts @@ -0,0 +1,229 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { registerSkillHooks } from './registerSkillHooks.js'; +import { SessionHooksManager } from './sessionHooksManager.js'; +import { HookEventName, HookType } from './types.js'; +import type { SkillConfig } from '../skills/types.js'; + +describe('registerSkillHooks', () => { + let sessionHooksManager: SessionHooksManager; + const sessionId = 'test-session'; + const skillRoot = '/path/to/skill'; + + beforeEach(() => { + sessionHooksManager = new SessionHooksManager(); + }); + + it('should return 0 when skill has no hooks', () => { + const skill: SkillConfig = { + name: 'test-skill', + description: 'Test skill', + level: 'user', + filePath: '/path/to/skill/SKILL.md', + body: 'Test body', + }; + + const count = registerSkillHooks(sessionHooksManager, sessionId, skill); + expect(count).toBe(0); + }); + + it('should register a single command hook', () => { + const skill: SkillConfig = { + name: 'test-skill', + description: 'Test skill', + level: 'user', + filePath: '/path/to/skill/SKILL.md', + skillRoot, + body: 'Test body', + hooks: { + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Command, + command: 'echo "checking command"', + }, + ], + }, + ], + }, + }; + + const count = registerSkillHooks(sessionHooksManager, sessionId, skill); + expect(count).toBe(1); + expect(sessionHooksManager.hasSessionHooks(sessionId)).toBe(true); + }); + + it('should register multiple hooks for different events', () => { + const skill: SkillConfig = { + name: 'test-skill', + description: 'Test skill', + level: 'user', + filePath: '/path/to/skill/SKILL.md', + skillRoot, + body: 'Test body', + hooks: { + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Command, + command: 'echo "pre-tool-use"', + }, + ], + }, + ], + [HookEventName.PostToolUse]: [ + { + matcher: 'Write', + hooks: [ + { + type: HookType.Command, + command: 'echo "post-tool-use"', + }, + ], + }, + ], + }, + }; + + const count = registerSkillHooks(sessionHooksManager, sessionId, skill); + expect(count).toBe(2); + }); + + it('should register HTTP hooks', () => { + const skill: SkillConfig = { + name: 'test-skill', + description: 'Test skill', + level: 'user', + filePath: '/path/to/skill/SKILL.md', + skillRoot, + body: 'Test body', + hooks: { + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Http, + url: 'https://example.com/hook', + headers: { + Authorization: 'Bearer token', + }, + }, + ], + }, + ], + }, + }; + + const count = registerSkillHooks(sessionHooksManager, sessionId, skill); + expect(count).toBe(1); + }); + + it('should register hooks with matcher pattern', () => { + const skill: SkillConfig = { + name: 'test-skill', + description: 'Test skill', + level: 'user', + filePath: '/path/to/skill/SKILL.md', + skillRoot, + body: 'Test body', + hooks: { + [HookEventName.PreToolUse]: [ + { + matcher: '^(Write|Edit)$', + hooks: [ + { + type: HookType.Command, + command: 'echo "file operation"', + }, + ], + }, + ], + }, + }; + + const count = registerSkillHooks(sessionHooksManager, sessionId, skill); + expect(count).toBe(1); + + const hooks = sessionHooksManager.getHooksForEvent( + sessionId, + HookEventName.PreToolUse, + ); + expect(hooks).toHaveLength(1); + expect(hooks[0].matcher).toBe('^(Write|Edit)$'); + }); + + it('should register multiple hooks for same event and matcher', () => { + const skill: SkillConfig = { + name: 'test-skill', + description: 'Test skill', + level: 'user', + filePath: '/path/to/skill/SKILL.md', + skillRoot, + body: 'Test body', + hooks: { + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Command, + command: 'echo "first check"', + }, + { + type: HookType.Command, + command: 'echo "second check"', + }, + ], + }, + ], + }, + }; + + const count = registerSkillHooks(sessionHooksManager, sessionId, skill); + expect(count).toBe(2); + }); + + it('should register hooks with skillRoot for environment variable', () => { + const skill: SkillConfig = { + name: 'test-skill', + description: 'Test skill', + level: 'user', + filePath: '/path/to/skill/SKILL.md', + skillRoot, + body: 'Test body', + hooks: { + [HookEventName.PreToolUse]: [ + { + matcher: 'Bash', + hooks: [ + { + type: HookType.Command, + command: 'echo $QWEN_SKILL_ROOT', + }, + ], + }, + ], + }, + }; + + const count = registerSkillHooks(sessionHooksManager, sessionId, skill); + expect(count).toBe(1); + + const hooks = sessionHooksManager.getHooksForEvent( + sessionId, + HookEventName.PreToolUse, + ); + expect(hooks).toHaveLength(1); + expect(hooks[0].skillRoot).toBe(skillRoot); + }); +}); diff --git a/packages/core/src/hooks/registerSkillHooks.ts b/packages/core/src/hooks/registerSkillHooks.ts new file mode 100644 index 000000000..bac45b89f --- /dev/null +++ b/packages/core/src/hooks/registerSkillHooks.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Skill Hooks Registration + * + * Registers hooks from a skill's frontmatter as session-scoped hooks. + * When a skill is invoked, its hooks are registered for the duration + * of the session. + */ + +import { createDebugLogger } from '../utils/debugLogger.js'; +import type { SessionHooksManager } from './sessionHooksManager.js'; +import type { SkillHooksSettings, SkillConfig } from '../skills/types.js'; +import { + HookType, + type HookEventName, + type CommandHookConfig, + type HttpHookConfig, +} from './types.js'; + +const debugLogger = createDebugLogger('SKILL_HOOKS'); + +/** + * Registers hooks from a skill's configuration as session hooks. + * + * Hooks are registered as session-scoped hooks that persist for the duration + * of the session. If a hook has `once: true` in its configuration, it will be + * automatically removed after its first successful execution. + * + * @param sessionHooksManager - The session hooks manager instance + * @param sessionId - The current session ID + * @param skill - The skill configuration containing hooks + * @returns Number of hooks registered + */ +export function registerSkillHooks( + sessionHooksManager: SessionHooksManager, + sessionId: string, + skill: SkillConfig, +): number { + if (!skill.hooks) { + debugLogger.debug(`Skill '${skill.name}' has no hooks to register`); + return 0; + } + + const hooksSettings: SkillHooksSettings = skill.hooks; + let registeredCount = 0; + + for (const eventName of Object.keys(hooksSettings) as HookEventName[]) { + const matchers = hooksSettings[eventName]; + if (!matchers) continue; + + for (const matcher of matchers) { + const matcherPattern = matcher.matcher || ''; + + for (const hook of matcher.hooks) { + // Only register command and HTTP hooks (skip function hooks) + if (hook.type === HookType.Function) { + debugLogger.debug( + 'Skipping function hook from skill (not supported in frontmatter)', + ); + continue; + } + + // Register the hook with skillRoot for environment variable + const hookConfig = prepareHookConfig( + hook as CommandHookConfig | HttpHookConfig, + skill.skillRoot, + ); + + sessionHooksManager.addSessionHook( + sessionId, + eventName, + matcherPattern, + hookConfig, + { skillRoot: skill.skillRoot }, + ); + + registeredCount++; + debugLogger.debug( + `Registered hook for ${eventName} with matcher '${matcherPattern}' from skill '${skill.name}'`, + ); + } + } + } + + if (registeredCount > 0) { + debugLogger.info( + `Registered ${registeredCount} hooks from skill '${skill.name}'`, + ); + } + + return registeredCount; +} + +/** + * Prepares hook config with skillRoot environment variable. + * + * @param hook - The hook configuration + * @param skillRoot - The skill root directory + * @returns Prepared hook configuration + */ +function prepareHookConfig( + hook: CommandHookConfig | HttpHookConfig, + skillRoot?: string, +): CommandHookConfig | HttpHookConfig { + if (hook.type === 'command' && skillRoot) { + // Add QWEN_SKILL_ROOT to environment variables + return { + ...hook, + env: { + ...hook.env, + QWEN_SKILL_ROOT: skillRoot, + }, + }; + } + + return hook; +} + +/** + * Unregisters all hooks from a skill. + * + * Note: This is typically not needed as session hooks are cleared + * when the session ends. However, it can be useful for cleanup + * in certain scenarios. + * + * @param sessionHooksManager - The session hooks manager instance + * @param sessionId - The current session ID + * @param skill - The skill configuration + * @returns Number of hooks unregistered + */ +export function unregisterSkillHooks( + sessionHooksManager: SessionHooksManager, + sessionId: string, + skill: SkillConfig, +): number { + if (!skill.hooks) { + return 0; + } + + // Note: Current implementation doesn't track hook IDs per skill + // Session hooks are cleared when session ends + debugLogger.debug( + `Skill hooks for '${skill.name}' will be cleared with session`, + ); + + return 0; +} diff --git a/packages/core/src/hooks/sessionHooksManager.test.ts b/packages/core/src/hooks/sessionHooksManager.test.ts new file mode 100644 index 000000000..f70d16496 --- /dev/null +++ b/packages/core/src/hooks/sessionHooksManager.test.ts @@ -0,0 +1,694 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SessionHooksManager } from './sessionHooksManager.js'; +import { HookEventName, HookType } from './types.js'; +import type { CommandHookConfig, HttpHookConfig } from './types.js'; + +describe('SessionHooksManager', () => { + let manager: SessionHooksManager; + + beforeEach(() => { + manager = new SessionHooksManager(); + }); + + describe('addFunctionHook', () => { + it('should add a function hook and return hook ID', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + const hookId = manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error message', + ); + + expect(hookId).toBeDefined(); + expect(manager.hasSessionHooks('session-1')).toBe(true); + }); + + it('should use provided hook ID', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + const returnedHookId = manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error message', + { id: 'custom-hook-id' }, + ); + + expect(returnedHookId).toBe('custom-hook-id'); + }); + + it('should add hook with options', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error message', + { + timeout: 30000, + name: 'My Hook', + description: 'Test hook', + }, + ); + + const hooks = manager.getHooksForEvent( + 'session-1', + HookEventName.PreToolUse, + ); + expect(hooks.length).toBe(1); + expect(hooks[0].config.name).toBe('My Hook'); + }); + }); + + describe('addSessionHook', () => { + it('should add a command hook', () => { + const commandHook: CommandHookConfig = { + type: HookType.Command, + command: 'echo "test"', + name: 'Test Command', + }; + + const hookId = manager.addSessionHook( + 'session-1', + HookEventName.PostToolUse, + '*', + commandHook, + ); + + expect(hookId).toBeDefined(); + const hooks = manager.getHooksForEvent( + 'session-1', + HookEventName.PostToolUse, + ); + expect(hooks.length).toBe(1); + expect(hooks[0].config.type).toBe(HookType.Command); + }); + + it('should add an HTTP hook', () => { + const httpHook: HttpHookConfig = { + type: HookType.Http, + url: 'https://api.example.com/hook', + name: 'Test HTTP', + }; + + const hookId = manager.addSessionHook( + 'session-1', + HookEventName.PostToolUse, + 'Write', + httpHook, + ); + + expect(hookId).toBeDefined(); + const hooks = manager.getHooksForEvent( + 'session-1', + HookEventName.PostToolUse, + ); + expect(hooks.length).toBe(1); + expect(hooks[0].config.type).toBe(HookType.Http); + }); + }); + + describe('removeFunctionHook', () => { + it('should remove hook by ID', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + const hookId = manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + const removed = manager.removeFunctionHook( + 'session-1', + HookEventName.PreToolUse, + hookId, + ); + + expect(removed).toBe(true); + expect(manager.hasSessionHooks('session-1')).toBe(false); + }); + + it('should return false for non-existent hook', () => { + const removed = manager.removeFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'non-existent', + ); + + expect(removed).toBe(false); + }); + }); + + describe('removeHook', () => { + it('should remove hook by ID across all events', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + const hookId = manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + const removed = manager.removeHook('session-1', hookId); + + expect(removed).toBe(true); + expect(manager.hasSessionHooks('session-1')).toBe(false); + }); + }); + + describe('getHooksForEvent', () => { + it('should return hooks for specific event', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + manager.addFunctionHook( + 'session-1', + HookEventName.PostToolUse, + '*', + callback, + 'Test error', + ); + + const preToolHooks = manager.getHooksForEvent( + 'session-1', + HookEventName.PreToolUse, + ); + const postToolHooks = manager.getHooksForEvent( + 'session-1', + HookEventName.PostToolUse, + ); + + expect(preToolHooks.length).toBe(1); + expect(postToolHooks.length).toBe(1); + }); + + it('should return empty array for non-existent session', () => { + const hooks = manager.getHooksForEvent( + 'non-existent', + HookEventName.PreToolUse, + ); + expect(hooks).toEqual([]); + }); + }); + + describe('getMatchingHooks', () => { + it('should match exact tool name', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + const matching = manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + ); + + expect(matching.length).toBe(1); + }); + + it('should match wildcard *', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + '*', + callback, + 'Test error', + ); + + const matching = manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'AnyTool', + ); + + expect(matching.length).toBe(1); + }); + + it('should match pipe-separated alternatives', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Write|Edit|Read', + callback, + 'Test error', + ); + + expect( + manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Write') + .length, + ).toBe(1); + expect( + manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Edit') + .length, + ).toBe(1); + expect( + manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Read') + .length, + ).toBe(1); + expect( + manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'Delete', + ).length, + ).toBe(0); + }); + + it('should not match different tool name', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + const matching = manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'Write', + ); + + expect(matching.length).toBe(0); + }); + }); + + describe('hasSessionHooks', () => { + it('should return true when session has hooks', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + expect(manager.hasSessionHooks('session-1')).toBe(true); + }); + + it('should return false when session has no hooks', () => { + expect(manager.hasSessionHooks('session-1')).toBe(false); + }); + + it('should return false after all hooks removed', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + const hookId = manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + manager.removeHook('session-1', hookId); + + expect(manager.hasSessionHooks('session-1')).toBe(false); + }); + }); + + describe('clearSessionHooks', () => { + it('should clear all hooks for a session', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + manager.addFunctionHook( + 'session-1', + HookEventName.PostToolUse, + '*', + callback, + 'Test error', + ); + + manager.clearSessionHooks('session-1'); + + expect(manager.hasSessionHooks('session-1')).toBe(false); + }); + + it('should not affect other sessions', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + manager.addFunctionHook( + 'session-2', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + manager.clearSessionHooks('session-1'); + + expect(manager.hasSessionHooks('session-1')).toBe(false); + expect(manager.hasSessionHooks('session-2')).toBe(true); + }); + }); + + describe('getActiveSessions', () => { + it('should return all session IDs with hooks', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + manager.addFunctionHook( + 'session-2', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + const sessions = manager.getActiveSessions(); + expect(sessions).toContain('session-1'); + expect(sessions).toContain('session-2'); + }); + }); + + describe('getHookCount', () => { + it('should return correct hook count', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + manager.addFunctionHook( + 'session-1', + HookEventName.PostToolUse, + '*', + callback, + 'Test error', + ); + + expect(manager.getHookCount('session-1')).toBe(2); + }); + + it('should return 0 for non-existent session', () => { + expect(manager.getHookCount('non-existent')).toBe(0); + }); + }); + + describe('regex matcher support', () => { + it('should match using regex pattern', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + '^Bash.*', + callback, + 'Test error', + ); + + expect( + manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Bash') + .length, + ).toBe(1); + expect( + manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'BashAction', + ).length, + ).toBe(1); + expect( + manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Write') + .length, + ).toBe(0); + }); + + it('should match using regex with anchors', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + '^(Write|Edit)$', + callback, + 'Test error', + ); + + expect( + manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Write') + .length, + ).toBe(1); + expect( + manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Edit') + .length, + ).toBe(1); + // Should not match WriteOrEdit because of anchors + expect( + manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'WriteOrEdit', + ).length, + ).toBe(0); + }); + + it('should fallback to exact match for invalid regex', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + // Invalid regex pattern - unclosed bracket + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + '[invalid', + callback, + 'Test error', + ); + + // Should fallback to exact match + expect( + manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + '[invalid', + ).length, + ).toBe(1); + expect( + manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Bash') + .length, + ).toBe(0); + }); + }); + + describe('skillRoot support', () => { + it('should store skillRoot in hook entry', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + { skillRoot: '/path/to/skill' }, + ); + + const hooks = manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + ); + + expect(hooks.length).toBe(1); + expect(hooks[0].skillRoot).toBe('/path/to/skill'); + }); + + it('should work without skillRoot', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Test error', + ); + + const hooks = manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + ); + + expect(hooks.length).toBe(1); + expect(hooks[0].skillRoot).toBeUndefined(); + }); + + it('should filter hooks by skillRoot', () => { + const callback1 = vi.fn().mockResolvedValue({ continue: true }); + const callback2 = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback1, + 'Error 1', + { skillRoot: '/skill-a' }, + ); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback2, + 'Error 2', + { skillRoot: '/skill-b' }, + ); + + const hooks = manager.getMatchingHooks( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + ); + + expect(hooks.length).toBe(2); + expect(hooks[0].skillRoot).toBe('/skill-a'); + expect(hooks[1].skillRoot).toBe('/skill-b'); + }); + }); + + describe('getAllSessionHooks', () => { + it('should return empty array for non-existent session', () => { + const hooks = manager.getAllSessionHooks('non-existent-session'); + expect(hooks).toEqual([]); + }); + + it('should return all hooks across all events', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Error', + ); + + manager.addFunctionHook( + 'session-1', + HookEventName.PostToolUse, + 'Write', + callback, + 'Error', + ); + + manager.addFunctionHook( + 'session-1', + HookEventName.Stop, + '', + callback, + 'Error', + ); + + const hooks = manager.getAllSessionHooks('session-1'); + + expect(hooks).toHaveLength(3); + expect(hooks.map((h) => h.eventName).sort()).toEqual([ + HookEventName.PostToolUse, + HookEventName.PreToolUse, + HookEventName.Stop, + ]); + }); + + it('should include session hooks with skillRoot', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Error', + { skillRoot: '/my-skill' }, + ); + + const hooks = manager.getAllSessionHooks('session-1'); + + expect(hooks).toHaveLength(1); + expect(hooks[0].skillRoot).toBe('/my-skill'); + }); + + it('should return copy of hooks array', () => { + const callback = vi.fn().mockResolvedValue({ continue: true }); + + manager.addFunctionHook( + 'session-1', + HookEventName.PreToolUse, + 'Bash', + callback, + 'Error', + ); + + const hooks1 = manager.getAllSessionHooks('session-1'); + const hooks2 = manager.getAllSessionHooks('session-1'); + + expect(hooks1).not.toBe(hooks2); // Different array references + expect(hooks1).toEqual(hooks2); // Same content + }); + }); +}); diff --git a/packages/core/src/hooks/sessionHooksManager.ts b/packages/core/src/hooks/sessionHooksManager.ts new file mode 100644 index 000000000..7d2748418 --- /dev/null +++ b/packages/core/src/hooks/sessionHooksManager.ts @@ -0,0 +1,369 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger } from '../utils/debugLogger.js'; +import type { + HookEventName, + CommandHookConfig, + HttpHookConfig, + FunctionHookConfig, + FunctionHookCallback, + HookConfig, + HookExecutionResult, +} from './types.js'; +import { HookType } from './types.js'; + +const debugLogger = createDebugLogger('SESSION_HOOKS_MANAGER'); + +/** + * Generate a unique hook ID + */ +function generateHookId(): string { + return `session_hook_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Session hook entry with matcher and configuration + */ +export interface SessionHookEntry { + hookId: string; + eventName: HookEventName; + matcher: string; + config: HookConfig; + sequential?: boolean; + /** Optional skill root path for skill-scoped hooks */ + skillRoot?: string; +} + +/** + * Session hooks storage per session + */ +interface SessionHooksStorage { + hooks: Map; +} + +/** + * Session Hooks Manager - manages hooks registered at runtime for specific sessions + * Used primarily for SDK integration where hooks are registered programmatically + */ +export class SessionHooksManager { + private readonly sessions: Map = new Map(); + + /** + * Get or create session storage + */ + private getSessionStorage(sessionId: string): SessionHooksStorage { + let storage = this.sessions.get(sessionId); + if (!storage) { + storage = { hooks: new Map() }; + this.sessions.set(sessionId, storage); + } + return storage; + } + + /** + * Add a function hook for a session + * @param sessionId Session ID + * @param event Hook event name + * @param matcher Matcher pattern (e.g., 'Bash', '*', 'Write|Edit', or regex) + * @param callback Function callback to execute + * @param errorMessage Error message to display on failure + * @param options Additional options + * @returns Hook ID for later removal + */ + addFunctionHook( + sessionId: string, + event: HookEventName, + matcher: string, + callback: FunctionHookCallback, + errorMessage: string, + options?: { + timeout?: number; + id?: string; + name?: string; + description?: string; + statusMessage?: string; + onHookSuccess?: (result: HookExecutionResult) => void; + skillRoot?: string; + }, + ): string { + const hookId = options?.id || generateHookId(); + + const config: FunctionHookConfig = { + type: HookType.Function, + id: hookId, + name: options?.name, + description: options?.description, + timeout: options?.timeout, + callback, + errorMessage, + statusMessage: options?.statusMessage, + onHookSuccess: options?.onHookSuccess, + }; + + const entry: SessionHookEntry = { + hookId, + eventName: event, + matcher, + config, + skillRoot: options?.skillRoot, + }; + + const storage = this.getSessionStorage(sessionId); + const eventHooks = storage.hooks.get(event) || []; + eventHooks.push(entry); + storage.hooks.set(event, eventHooks); + + debugLogger.debug( + `Added function hook ${hookId} for session ${sessionId} on event ${event}`, + ); + + return hookId; + } + + /** + * Add a command or HTTP hook for a session + * @param sessionId Session ID + * @param event Hook event name + * @param matcher Matcher pattern + * @param hook Hook configuration (command or HTTP) + * @param options Additional options + */ + addSessionHook( + sessionId: string, + event: HookEventName, + matcher: string, + hook: CommandHookConfig | HttpHookConfig, + options?: { sequential?: boolean; skillRoot?: string }, + ): string { + const hookId = generateHookId(); + + const entry: SessionHookEntry = { + hookId, + eventName: event, + matcher, + config: hook, + sequential: options?.sequential, + skillRoot: options?.skillRoot, + }; + + const storage = this.getSessionStorage(sessionId); + const eventHooks = storage.hooks.get(event) || []; + eventHooks.push(entry); + storage.hooks.set(event, eventHooks); + + debugLogger.debug( + `Added session hook ${hookId} for session ${sessionId} on event ${event}`, + ); + + return hookId; + } + + /** + * Remove a function hook by ID + * @param sessionId Session ID + * @param event Hook event name + * @param hookId Hook ID to remove + * @returns True if hook was found and removed + */ + removeFunctionHook( + sessionId: string, + event: HookEventName, + hookId: string, + ): boolean { + const storage = this.sessions.get(sessionId); + if (!storage) { + return false; + } + + const eventHooks = storage.hooks.get(event); + if (!eventHooks) { + return false; + } + + const index = eventHooks.findIndex((entry) => entry.hookId === hookId); + if (index === -1) { + return false; + } + + eventHooks.splice(index, 1); + debugLogger.debug( + `Removed hook ${hookId} from session ${sessionId} on event ${event}`, + ); + + return true; + } + + /** + * Remove a hook by ID (searches all events) + * @param sessionId Session ID + * @param hookId Hook ID to remove + * @returns True if hook was found and removed + */ + removeHook(sessionId: string, hookId: string): boolean { + const storage = this.sessions.get(sessionId); + if (!storage) { + return false; + } + + for (const [event, eventHooks] of storage.hooks.entries()) { + const index = eventHooks.findIndex((entry) => entry.hookId === hookId); + if (index !== -1) { + eventHooks.splice(index, 1); + debugLogger.debug( + `Removed hook ${hookId} from session ${sessionId} on event ${event}`, + ); + return true; + } + } + + return false; + } + + /** + * Get all hooks for a session and event + * @param sessionId Session ID + * @param event Hook event name + * @returns Array of session hook entries + */ + getHooksForEvent( + sessionId: string, + event: HookEventName, + ): SessionHookEntry[] { + const storage = this.sessions.get(sessionId); + if (!storage) { + return []; + } + + return storage.hooks.get(event) || []; + } + + /** + * Get hooks that match a specific tool/target + * @param sessionId Session ID + * @param event Hook event name + * @param target Target to match (e.g., tool name) + * @returns Array of matching hook entries + */ + getMatchingHooks( + sessionId: string, + event: HookEventName, + target: string, + ): SessionHookEntry[] { + const hooks = this.getHooksForEvent(sessionId, event); + return hooks.filter((entry) => this.matchesPattern(entry.matcher, target)); + } + + /** + * Check if a target matches a pattern + * Supports: exact match, '*' wildcard, '|' for alternatives, regex syntax + * + * Matching priority: + * 1. '*' - matches everything + * 2. Pipe-separated alternatives (e.g., 'Write|Edit|Read') + * 3. Regex syntax (e.g., '^Bash.*', 'Write|Edit') + * 4. Exact match (fallback) + */ + private matchesPattern(pattern: string, target: string): boolean { + if (pattern === '*') { + return true; + } + + // Handle pipe-separated alternatives + if ( + pattern.includes('|') && + !pattern.startsWith('^') && + !pattern.startsWith('(') + ) { + const alternatives = pattern.split('|').map((s) => s.trim()); + return alternatives.some((alt) => this.matchesPattern(alt, target)); + } + + // Try regex match + try { + const regex = new RegExp(`^${pattern}$`); + return regex.test(target); + } catch { + // Invalid regex, fall back to exact match + debugLogger.debug( + `Invalid regex pattern "${pattern}", falling back to exact match`, + ); + } + + // Exact match (fallback) + return pattern === target; + } + + /** + * Check if a session has any hooks registered + * @param sessionId Session ID + * @returns True if session has hooks + */ + hasSessionHooks(sessionId: string): boolean { + const storage = this.sessions.get(sessionId); + if (!storage) { + return false; + } + + for (const eventHooks of storage.hooks.values()) { + if (eventHooks.length > 0) { + return true; + } + } + + return false; + } + + /** + * Clear all hooks for a session + * @param sessionId Session ID + */ + clearSessionHooks(sessionId: string): void { + this.sessions.delete(sessionId); + debugLogger.debug(`Cleared all hooks for session ${sessionId}`); + } + + /** + * Get all session IDs with registered hooks + */ + getActiveSessions(): string[] { + return Array.from(this.sessions.keys()); + } + + /** + * Get hook count for a session + */ + getHookCount(sessionId: string): number { + const storage = this.sessions.get(sessionId); + if (!storage) { + return 0; + } + + let count = 0; + for (const eventHooks of storage.hooks.values()) { + count += eventHooks.length; + } + return count; + } + + /** + * Get all hooks for a session across all events + * @param sessionId Session ID + * @returns Array of all session hook entries + */ + getAllSessionHooks(sessionId: string): SessionHookEntry[] { + const storage = this.sessions.get(sessionId); + if (!storage) { + return []; + } + + const allHooks: SessionHookEntry[] = []; + for (const eventHooks of storage.hooks.values()) { + allHooks.push(...eventHooks); + } + return allHooks; + } +} diff --git a/packages/core/src/hooks/ssrfGuard.test.ts b/packages/core/src/hooks/ssrfGuard.test.ts new file mode 100644 index 000000000..b72453fc9 --- /dev/null +++ b/packages/core/src/hooks/ssrfGuard.test.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { isBlockedAddress, ssrfGuardedLookup } from './ssrfGuard.js'; + +function lookupAsync( + hostname: string, + options?: { all?: boolean }, +): Promise<{ + err: Error | null; + address: string | Array<{ address: string; family: number }>; + family?: number; +}> { + return new Promise((resolve) => { + ssrfGuardedLookup(hostname, options ?? {}, (err, address, family) => { + resolve({ err, address, family }); + }); + }); +} + +describe('ssrfGuard', () => { + describe('isBlockedAddress', () => { + it('should block 10.0.0.0/8 (private)', () => { + expect(isBlockedAddress('10.0.0.1')).toBe(true); + expect(isBlockedAddress('10.255.255.255')).toBe(true); + }); + + it('should block 172.16.0.0/12 (private)', () => { + expect(isBlockedAddress('172.16.0.1')).toBe(true); + expect(isBlockedAddress('172.31.255.255')).toBe(true); + expect(isBlockedAddress('172.15.255.255')).toBe(false); + expect(isBlockedAddress('172.32.0.0')).toBe(false); + }); + + it('should block 192.168.0.0/16 (private)', () => { + expect(isBlockedAddress('192.168.0.1')).toBe(true); + expect(isBlockedAddress('192.168.255.255')).toBe(true); + }); + + it('should block 169.254.0.0/16 (link-local)', () => { + expect(isBlockedAddress('169.254.169.254')).toBe(true); + expect(isBlockedAddress('169.254.0.0')).toBe(true); + }); + + it('should block 100.64.0.0/10 (CGNAT)', () => { + expect(isBlockedAddress('100.64.0.0')).toBe(true); + expect(isBlockedAddress('100.100.100.200')).toBe(true); + expect(isBlockedAddress('100.127.255.255')).toBe(true); + expect(isBlockedAddress('100.63.255.255')).toBe(false); + expect(isBlockedAddress('100.128.0.0')).toBe(false); + }); + + it('should block 0.0.0.0/8', () => { + expect(isBlockedAddress('0.0.0.0')).toBe(true); + expect(isBlockedAddress('0.255.255.255')).toBe(true); + }); + + it('should ALLOW 127.0.0.0/8 (loopback) for local dev', () => { + expect(isBlockedAddress('127.0.0.1')).toBe(false); + expect(isBlockedAddress('127.0.0.2')).toBe(false); + expect(isBlockedAddress('127.255.255.255')).toBe(false); + }); + + it('should ALLOW public IPs', () => { + expect(isBlockedAddress('8.8.8.8')).toBe(false); + expect(isBlockedAddress('1.1.1.1')).toBe(false); + expect(isBlockedAddress('203.0.113.1')).toBe(false); + }); + + it('should ALLOW ::1 (IPv6 loopback)', () => { + expect(isBlockedAddress('::1')).toBe(false); + }); + + it('should block :: (unspecified)', () => { + expect(isBlockedAddress('::')).toBe(true); + }); + + it('should block IPv6 unique local (fc00::/7)', () => { + expect(isBlockedAddress('fc00::1')).toBe(true); + expect(isBlockedAddress('fd00::1')).toBe(true); + expect(isBlockedAddress('fe00::1')).toBe(false); + }); + + it('should block IPv6 link-local (fe80::/10)', () => { + expect(isBlockedAddress('fe80::1')).toBe(true); + expect(isBlockedAddress('febf::1')).toBe(true); + expect(isBlockedAddress('fec0::1')).toBe(false); + }); + + it('should block IPv4-mapped IPv6 in private range', () => { + // ::ffff:a9fe:a9fe = 169.254.169.254 + expect(isBlockedAddress('::ffff:a9fe:a9fe')).toBe(true); + // ::ffff:c0a8:0101 = 192.168.1.1 + expect(isBlockedAddress('::ffff:c0a8:101')).toBe(true); + }); + + it('should allow IPv4-mapped IPv6 in loopback range', () => { + // ::ffff:7f00:1 = 127.0.0.1 + expect(isBlockedAddress('::ffff:7f00:1')).toBe(false); + }); + + it('should return false for non-IP hostnames', () => { + expect(isBlockedAddress('api.example.com')).toBe(false); + expect(isBlockedAddress('localhost')).toBe(false); + }); + }); + + describe('ssrfGuardedLookup', () => { + it('should block IP literals in private ranges', async () => { + const { err } = await lookupAsync('169.254.169.254'); + expect(err).toBeTruthy(); + expect((err as NodeJS.ErrnoException).code).toBe( + 'ERR_HTTP_HOOK_BLOCKED_ADDRESS', + ); + }); + + it('should allow IP literals in loopback range', async () => { + const { err, address, family } = await lookupAsync('127.0.0.1'); + expect(err).toBeNull(); + expect(address).toBe('127.0.0.1'); + expect(family).toBe(4); + }); + + it('should allow ::1 (IPv6 loopback)', async () => { + const { err, address, family } = await lookupAsync('::1'); + expect(err).toBeNull(); + expect(address).toBe('::1'); + expect(family).toBe(6); + }); + + it('should return all addresses when all=true', async () => { + const { err, address } = await lookupAsync('127.0.0.1', { all: true }); + expect(err).toBeNull(); + expect(Array.isArray(address)).toBe(true); + expect((address as Array<{ address: string }>).length).toBe(1); + }); + + it('should resolve DNS and validate IPs for hostnames', async () => { + // localhost typically resolves to 127.0.0.1 which is allowed + const { err, address } = await lookupAsync('localhost'); + expect(err).toBeNull(); + expect(address).toBeTruthy(); + }); + + it('should block localhost.localdomain', async () => { + // This is in BLOCKED_HOSTS list + const { err } = await lookupAsync('localhost.localdomain'); + // This hostname may not resolve, but the SSRF check happens after DNS + // Since it's not an IP literal, DNS resolution will be attempted + // The actual blocking depends on whether it resolves to a private IP + // For this test, we just check the function doesn't crash + expect(err).toBeDefined(); // Will likely fail DNS lookup + }); + }); +}); diff --git a/packages/core/src/hooks/ssrfGuard.ts b/packages/core/src/hooks/ssrfGuard.ts new file mode 100644 index 000000000..d331a4676 --- /dev/null +++ b/packages/core/src/hooks/ssrfGuard.ts @@ -0,0 +1,286 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isIP } from 'net'; +import * as dns from 'dns'; + +/** + * SSRF guard for HTTP hooks. + * + * Aligned with Claude Code's ssrfGuard.ts behavior. + * + * Blocks private, link-local, and other non-routable address ranges to prevent + * project-configured HTTP hooks from reaching cloud metadata endpoints + * (169.254.169.254) or internal infrastructure. + * + * Loopback (127.0.0.0/8, ::1) is intentionally ALLOWED — local dev policy + * servers are a primary HTTP hook use case. + * + * NOTE: Node.js native `fetch` does not support a custom `lookup` option + * (unlike axios). This module performs DNS validation before the request. + * There is a small race window between validation and connection where a + * sophisticated DNS rebinding attack could occur. For most threat models + * this is acceptable. For higher security, use a proxy or switch to axios. + */ + +/** + * Returns true if the address is in a range that HTTP hooks should not reach. + * + * Blocked IPv4: + * 0.0.0.0/8 "this" network + * 10.0.0.0/8 private + * 100.64.0.0/10 shared address space / CGNAT (some cloud metadata, e.g. Alibaba 100.100.100.200) + * 169.254.0.0/16 link-local (cloud metadata) + * 172.16.0.0/12 private + * 192.168.0.0/16 private + * + * Blocked IPv6: + * :: unspecified + * fc00::/7 unique local + * fe80::/10 link-local + * ::ffff: mapped IPv4 in a blocked range + * + * Allowed (returns false): + * 127.0.0.0/8 loopback (local dev hooks) + * ::1 loopback + * everything else + */ +export function isBlockedAddress(address: string): boolean { + const v = isIP(address); + if (v === 4) { + return isBlockedV4(address); + } + if (v === 6) { + return isBlockedV6(address); + } + // Not a valid IP literal — let the real DNS path handle it + return false; +} + +function isBlockedV4(address: string): boolean { + const parts = address.split('.').map(Number); + const [a, b] = parts; + if ( + parts.length !== 4 || + a === undefined || + b === undefined || + parts.some((n) => Number.isNaN(n)) + ) { + return false; + } + + // Loopback explicitly allowed + if (a === 127) return false; + + // 0.0.0.0/8 + if (a === 0) return true; + // 10.0.0.0/8 + if (a === 10) return true; + // 169.254.0.0/16 — link-local, cloud metadata + if (a === 169 && b === 254) return true; + // 172.16.0.0/12 + if (a === 172 && b >= 16 && b <= 31) return true; + // 100.64.0.0/10 — shared address space (RFC 6598, CGNAT) + if (a === 100 && b >= 64 && b <= 127) return true; + // 192.168.0.0/16 + if (a === 192 && b === 168) return true; + + return false; +} + +function isBlockedV6(address: string): boolean { + const lower = address.toLowerCase(); + + // ::1 loopback explicitly allowed + if (lower === '::1') return false; + + // :: unspecified + if (lower === '::') return true; + + // IPv4-mapped IPv6 (0:0:0:0:0:ffff:X:Y in any representation). + // Extract the embedded IPv4 address and delegate to the v4 check. + const mappedV4 = extractMappedIPv4(lower); + if (mappedV4 !== null) { + return isBlockedV4(mappedV4); + } + + // fc00::/7 — unique local addresses (fc00:: through fdff::) + if (lower.startsWith('fc') || lower.startsWith('fd')) { + return true; + } + + // fe80::/10 — link-local. The /10 means fe80 through febf. + const firstHextet = lower.split(':')[0]; + if ( + firstHextet && + firstHextet.length >= 3 && + firstHextet >= 'fe80' && + firstHextet <= 'febf' + ) { + return true; + } + + return false; +} + +/** + * Expand `::` and optional trailing dotted-decimal so an IPv6 address is + * represented as exactly 8 hex groups. Returns null if expansion is not + * well-formed. + */ +function expandIPv6Groups(addr: string): number[] | null { + // Handle trailing dotted-decimal IPv4 (e.g. ::ffff:169.254.169.254). + let tailHextets: number[] = []; + if (addr.includes('.')) { + const lastColon = addr.lastIndexOf(':'); + const v4 = addr.slice(lastColon + 1); + addr = addr.slice(0, lastColon); + const octets = v4.split('.').map(Number); + if ( + octets.length !== 4 || + octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255) + ) { + return null; + } + tailHextets = [ + (octets[0]! << 8) | octets[1]!, + (octets[2]! << 8) | octets[3]!, + ]; + } + + // Expand `::` (at most one) into the right number of zero groups. + const dbl = addr.indexOf('::'); + let head: string[]; + let tail: string[]; + if (dbl === -1) { + head = addr.split(':'); + tail = []; + } else { + const headStr = addr.slice(0, dbl); + const tailStr = addr.slice(dbl + 2); + head = headStr === '' ? [] : headStr.split(':'); + tail = tailStr === '' ? [] : tailStr.split(':'); + } + + const target = 8 - tailHextets.length; + const fill = target - head.length - tail.length; + if (fill < 0) return null; + + const hex = [...head, ...new Array(fill).fill('0'), ...tail]; + const nums = hex.map((h) => parseInt(h, 16)); + if (nums.some((n) => Number.isNaN(n) || n < 0 || n > 0xffff)) { + return null; + } + nums.push(...tailHextets); + return nums.length === 8 ? nums : null; +} + +/** + * Extract the embedded IPv4 address from an IPv4-mapped IPv6 address + * (0:0:0:0:0:ffff:X:Y) in any valid representation. + */ +function extractMappedIPv4(addr: string): string | null { + const g = expandIPv6Groups(addr); + if (!g) return null; + // IPv4-mapped: first 80 bits zero, next 16 bits ffff, last 32 bits = IPv4 + if ( + g[0] === 0 && + g[1] === 0 && + g[2] === 0 && + g[3] === 0 && + g[4] === 0 && + g[5] === 0xffff + ) { + const hi = g[6]!; + const lo = g[7]!; + return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; + } + return null; +} + +/** + * A dns.lookup-compatible function that resolves a hostname and rejects + * addresses in blocked ranges. Used as a custom lookup to validate the + * resolved IP before connecting. + */ +export function ssrfGuardedLookup( + hostname: string, + options: { all?: boolean }, + callback: ( + err: Error | null, + address: string | Array<{ address: string; family: number }>, + family?: number, + ) => void, +): void { + const wantsAll = 'all' in options && options.all === true; + + // If hostname is already an IP literal, validate it directly. + const ipVersion = isIP(hostname); + if (ipVersion !== 0) { + if (isBlockedAddress(hostname)) { + callback(ssrfError(hostname, hostname), ''); + return; + } + const family = ipVersion === 6 ? 6 : 4; + if (wantsAll) { + callback(null, [{ address: hostname, family }]); + } else { + callback(null, hostname, family); + } + return; + } + + // Resolve DNS and validate all returned IPs. + dns.promises + .lookup(hostname, { all: true }) + .then((addresses) => { + for (const { address } of addresses) { + if (isBlockedAddress(address)) { + callback(ssrfError(hostname, address), ''); + return; + } + } + + const first = addresses[0]; + if (!first) { + callback( + Object.assign(new Error(`ENOTFOUND ${hostname}`), { + code: 'ENOTFOUND', + hostname, + }), + '', + ); + return; + } + + const family = first.family === 6 ? 6 : 4; + if (wantsAll) { + callback( + null, + addresses.map((a) => ({ + address: a.address, + family: a.family === 6 ? 6 : 4, + })), + ); + } else { + callback(null, first.address, family); + } + }) + .catch((err) => { + callback(err, ''); + }); +} + +function ssrfError(hostname: string, address: string): NodeJS.ErrnoException { + const err = new Error( + `HTTP hook blocked: ${hostname} resolves to ${address} (private/link-local address). Loopback (127.0.0.1, ::1) is allowed for local dev.`, + ); + return Object.assign(err, { + code: 'ERR_HTTP_HOOK_BLOCKED_ADDRESS', + hostname, + address, + }); +} diff --git a/packages/core/src/hooks/trustedHooks.ts b/packages/core/src/hooks/trustedHooks.ts index 135fcc5b2..12cde2b9b 100644 --- a/packages/core/src/hooks/trustedHooks.ts +++ b/packages/core/src/hooks/trustedHooks.ts @@ -82,7 +82,13 @@ export class TrustedHooksManager { const key = getHookKey(hook); if (!trustedKeys.has(key)) { // Return friendly name or command - untrusted.push(hook.name || hook.command || 'unknown-hook'); + const hookIdentifier = + hook.name || + (hook.type === 'command' + ? (hook as { command?: string }).command + : undefined) || + 'unknown-hook'; + untrusted.push(hookIdentifier); } } } diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index 7a5f15f90..7b74b6cee 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -3,6 +3,7 @@ * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ +import type { ChildProcess } from 'child_process'; import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('TRUSTED_HOOKS'); @@ -12,6 +13,7 @@ export enum HooksConfigSource { User = 'user', System = 'system', Extensions = 'extensions', + Session = 'session', } /** @@ -54,7 +56,7 @@ export enum HookEventName { export const HOOKS_CONFIG_FIELDS = ['enabled', 'disabled', 'notifications']; /** - * Hook configuration entry + * Hook configuration entry for command hooks */ export interface CommandHookConfig { type: HookType.Command; @@ -64,9 +66,88 @@ export interface CommandHookConfig { timeout?: number; source?: HooksConfigSource; env?: Record; + async?: boolean; + shell?: 'bash' | 'powershell'; + /** Custom status message to display while hook is executing */ + statusMessage?: string; } -export type HookConfig = CommandHookConfig; +/** + * Hook configuration entry for HTTP hooks + */ +export interface HttpHookConfig { + type: HookType.Http; + url: string; + headers?: Record; + allowedEnvVars?: string[]; + timeout?: number; + if?: string; + name?: string; + description?: string; + statusMessage?: string; + once?: boolean; + source?: HooksConfigSource; +} + +/** + * Hook execution outcome - describes the result of hook execution + */ +export type HookExecutionOutcome = + | 'success' // Hook executed successfully + | 'blocking' // Hook blocked the operation + | 'non_blocking_error' // Hook failed but doesn't block + | 'cancelled'; // Hook was cancelled/aborted + +/** + * Context provided to function hooks for state access + */ +export interface FunctionHookContext { + /** Optional messages for conversation context */ + messages?: Array>; + /** Optional tool use ID for关联 to specific tool call */ + toolUseID?: string; + /** Optional abort signal for cancellation */ + signal?: AbortSignal; +} + +/** + * Function hook callback type + * Supports both simple boolean semantics and complex HookOutput semantics + * - Return boolean: true=success, false=blocking error + * - Return HookOutput: for advanced control over hook behavior + * - Return undefined: treated as {continue: true} (success) + */ +export type FunctionHookCallback = ( + input: HookInput, + context?: FunctionHookContext, +) => Promise; + +/** + * Hook configuration entry for function hooks (Session Hook specific) + */ +export interface FunctionHookConfig { + type: HookType.Function; + id?: string; + name?: string; + description?: string; + timeout?: number; + callback: FunctionHookCallback; + errorMessage: string; + statusMessage?: string; + /** Optional callback invoked on successful hook execution */ + onHookSuccess?: (result: HookExecutionResult) => void; +} + +/** + * Messages provider callback type for automatically passing conversation history + * to function hooks during execution + */ +export type MessagesProvider = () => Array> | undefined; + +export type HookConfig = + | CommandHookConfig + | HttpHookConfig + | FunctionHookConfig; /** * Hook definition with matcher @@ -82,6 +163,8 @@ export interface HookDefinition { */ export enum HookType { Command = 'command', + Http = 'http', + Function = 'function', } /** @@ -89,7 +172,18 @@ export enum HookType { */ export function getHookKey(hook: HookConfig): string { const name = hook.name ?? ''; - return name ? `${name}:${hook.command}` : hook.command; + switch (hook.type) { + case HookType.Command: + return name ? `${name}:${hook.command}` : hook.command; + case HookType.Http: + return name ? `${name}:${hook.url}` : hook.url; + case HookType.Function: + return name + ? `${name}:${hook.id ?? 'function'}` + : (hook.id ?? 'function'); + default: + return name || 'unknown'; + } } /** @@ -795,12 +889,15 @@ export interface HookExecutionResult { hookConfig: HookConfig; eventName: HookEventName; success: boolean; + /** Execution outcome for finer-grained result handling */ + outcome?: HookExecutionOutcome; output?: HookOutput; stdout?: string; stderr?: string; exitCode?: number; duration: number; error?: Error; + isAsync?: boolean; // Indicates if this was an async hook execution } /** @@ -811,3 +908,44 @@ export interface HookExecutionPlan { hookConfigs: HookConfig[]; sequential: boolean; } + +/** + * Pending async hook information + */ +export interface PendingAsyncHook { + hookId: string; + hookName: string; + hookEvent: HookEventName; + sessionId: string; + startTime: number; + timeout: number; + stdout: string; + stderr: string; + status: 'running' | 'completed' | 'failed' | 'timeout'; + output?: HookOutput; + error?: Error; + /** + * Reference to the child process for async command hooks. + * Used to terminate the process on timeout or cancellation. + */ + process?: ChildProcess; +} + +/** + * Async hook output message + */ +export interface AsyncHookOutputMessage { + type: 'system' | 'info' | 'warning' | 'error'; + message: string; + hookName: string; + hookId: string; + timestamp: number; +} + +/** + * Pending async output collection + */ +export interface PendingAsyncOutput { + messages: AsyncHookOutputMessage[]; + contexts: string[]; +} diff --git a/packages/core/src/hooks/urlValidator.test.ts b/packages/core/src/hooks/urlValidator.test.ts new file mode 100644 index 000000000..1a522540e --- /dev/null +++ b/packages/core/src/hooks/urlValidator.test.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { UrlValidator, createUrlValidator } from './urlValidator.js'; + +describe('UrlValidator', () => { + describe('isBlocked', () => { + it('should ALLOW 127.0.0.1 for local dev hooks', () => { + const validator = new UrlValidator([]); + expect(validator.isBlocked('http://127.0.0.1:8080/api')).toBe(false); + expect(validator.isBlocked('http://127.0.0.1/api')).toBe(false); + expect(validator.isBlocked('http://127.0.0.1:9876/hook')).toBe(false); + }); + + it('should ALLOW localhost for local dev hooks', () => { + const validator = new UrlValidator([]); + expect(validator.isBlocked('http://localhost:8080/api')).toBe(false); + expect(validator.isBlocked('http://localhost:9876/hook')).toBe(false); + }); + + it('should block private IP 192.168.x.x', () => { + const validator = new UrlValidator([]); + expect(validator.isBlocked('http://192.168.1.1/api')).toBe(true); + expect(validator.isBlocked('http://192.168.0.100:8080/api')).toBe(true); + }); + + it('should block private IP 10.x.x.x', () => { + const validator = new UrlValidator([]); + expect(validator.isBlocked('http://10.0.0.1/api')).toBe(true); + expect(validator.isBlocked('http://10.255.255.255/api')).toBe(true); + }); + + it('should block private IP 172.16.x.x - 172.31.x.x', () => { + const validator = new UrlValidator([]); + expect(validator.isBlocked('http://172.16.0.1/api')).toBe(true); + expect(validator.isBlocked('http://172.31.255.255/api')).toBe(true); + }); + + it('should block cloud metadata endpoints', () => { + const validator = new UrlValidator([]); + expect( + validator.isBlocked('http://169.254.169.254/latest/meta-data'), + ).toBe(true); + expect( + validator.isBlocked('http://metadata.google.internal/computeMetadata'), + ).toBe(true); + }); + + it('should allow public URLs', () => { + const validator = new UrlValidator([]); + expect(validator.isBlocked('https://api.example.com/hook')).toBe(false); + expect(validator.isBlocked('https://webhook.site/test')).toBe(false); + }); + + it('should block invalid URLs', () => { + const validator = new UrlValidator([]); + expect(validator.isBlocked('not-a-url')).toBe(true); + expect(validator.isBlocked('')).toBe(true); + }); + }); + + describe('isAllowed', () => { + it('should allow all URLs when no patterns configured', () => { + const validator = new UrlValidator([]); + expect(validator.isAllowed('https://any.example.com/api')).toBe(true); + }); + + it('should match exact URL pattern', () => { + const validator = new UrlValidator(['https://api\\.example\\.com/hook']); + expect(validator.isAllowed('https://api.example.com/hook')).toBe(true); + expect(validator.isAllowed('https://api.example.com/other')).toBe(false); + }); + + it('should match wildcard pattern', () => { + const validator = new UrlValidator(['https://api\\.example\\.com/*']); + expect(validator.isAllowed('https://api.example.com/hook')).toBe(true); + expect(validator.isAllowed('https://api.example.com/v1/hook')).toBe(true); + expect(validator.isAllowed('https://other.example.com/hook')).toBe(false); + }); + + it('should match multiple patterns', () => { + const validator = new UrlValidator([ + 'https://api\\.example\\.com/*', + 'https://webhook\\.site/*', + ]); + expect(validator.isAllowed('https://api.example.com/hook')).toBe(true); + expect(validator.isAllowed('https://webhook.site/test')).toBe(true); + expect(validator.isAllowed('https://other.com/hook')).toBe(false); + }); + + it('should be case insensitive', () => { + const validator = new UrlValidator(['https://API\\.Example\\.COM/*']); + expect(validator.isAllowed('https://api.example.com/hook')).toBe(true); + }); + }); + + describe('validate', () => { + it('should return allowed for valid public URL matching whitelist', () => { + const validator = new UrlValidator(['https://api\\.example\\.com/*']); + const result = validator.validate('https://api.example.com/hook'); + expect(result.allowed).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it('should return not allowed for blocked URL (private IP)', () => { + const validator = new UrlValidator(['*']); + const result = validator.validate('http://192.168.1.1:8080/api'); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('SSRF'); + }); + + it('should return allowed for localhost/loopback URLs', () => { + const validator = new UrlValidator(['*']); + const result1 = validator.validate('http://localhost:8080/api'); + expect(result1.allowed).toBe(true); + const result2 = validator.validate('http://127.0.0.1:9876/hook'); + expect(result2.allowed).toBe(true); + }); + + it('should return not allowed for URL not matching whitelist', () => { + const validator = new UrlValidator(['https://api\\.example\\.com/*']); + const result = validator.validate('https://other.com/hook'); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('does not match'); + }); + }); + + describe('createUrlValidator', () => { + it('should create validator with allowed URLs', () => { + const validator = createUrlValidator(['https://api\\.example\\.com/*']); + expect(validator.isAllowed('https://api.example.com/hook')).toBe(true); + }); + + it('should create validator with empty array', () => { + const validator = createUrlValidator([]); + expect(validator.isAllowed('https://any.com/hook')).toBe(true); + }); + + it('should create validator with undefined', () => { + const validator = createUrlValidator(undefined); + expect(validator.isAllowed('https://any.com/hook')).toBe(true); + }); + }); +}); diff --git a/packages/core/src/hooks/urlValidator.ts b/packages/core/src/hooks/urlValidator.ts new file mode 100644 index 000000000..5b893b699 --- /dev/null +++ b/packages/core/src/hooks/urlValidator.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isIPv4, isIPv6 } from 'net'; +import { isBlockedAddress } from './ssrfGuard.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('URL_VALIDATOR'); + +/** + * Hostnames that should be blocked for SSRF protection + * Note: 'localhost' is intentionally ALLOWED for local dev hooks (matches Claude Code behavior) + */ +const BLOCKED_HOSTS = [ + 'localhost.localdomain', + 'ip6-localhost', + 'ip6-loopback', + 'metadata.google.internal', // GCP metadata + '169.254.169.254', // Cloud metadata (AWS, GCP, Azure) + 'metadata.azure.internal', // Azure metadata +]; + +/** + * URL validator for HTTP hooks with whitelist and SSRF protection. + * + * SSRF protection uses the authoritative ssrfGuard.ts module for IP blocking. + * This module focuses on URL whitelist validation and hostname blocklist. + */ +export class UrlValidator { + private readonly allowedPatterns: string[]; + private readonly compiledPatterns: RegExp[]; + + /** + * Create a new URL validator + * @param allowedPatterns - Array of allowed URL patterns (supports * wildcard) + */ + constructor(allowedPatterns: string[] = []) { + this.allowedPatterns = allowedPatterns; + this.compiledPatterns = allowedPatterns.map((pattern) => + this.compilePattern(pattern), + ); + } + + /** + * Compile a URL pattern with wildcards into a RegExp. + * Supports both pre-escaped patterns (e.g., 'https://api\\.example\\.com/*') + * and unescaped patterns (e.g., 'https://api.example.com/*'). + */ + private compilePattern(pattern: string): RegExp { + // Check if pattern is already escaped (contains \. sequence) + const isPreEscaped = pattern.includes('\\.'); + + let escaped: string; + if (isPreEscaped) { + // Pattern is already escaped, only convert * to .* + escaped = pattern.replace(/\*/g, '.*'); + } else { + // Escape special regex characters except * + escaped = pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + } + return new RegExp(`^${escaped}$`, 'i'); + } + + /** + * Check if a URL is allowed by the whitelist + * @param url - The URL to check + * @returns True if the URL matches any allowed pattern + */ + isAllowed(url: string): boolean { + // If no patterns configured, allow all (but still check for blocked) + if (this.allowedPatterns.length === 0) { + return true; + } + + return this.compiledPatterns.some((pattern) => pattern.test(url)); + } + + /** + * Check if a URL should be blocked for security reasons (SSRF protection). + * Uses ssrfGuard.ts for IP address blocking (authoritative implementation). + * @param url - The URL to check + * @returns True if the URL should be blocked + */ + isBlocked(url: string): boolean { + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + + // Check blocked hostnames (metadata endpoints, etc.) + if (BLOCKED_HOSTS.includes(hostname)) { + debugLogger.debug(`URL blocked: hostname ${hostname} is in blocklist`); + return true; + } + + // Check if hostname is an IP address - use ssrfGuard for authoritative check + if (this.isIpAddress(hostname)) { + // Remove brackets from IPv6 addresses for isBlockedAddress + const cleanHostname = hostname.replace(/^\[|\]$/g, ''); + if (isBlockedAddress(cleanHostname)) { + debugLogger.debug(`URL blocked: IP ${hostname} is blocked`); + return true; + } + } + + return false; + } catch { + // Invalid URL, block it + debugLogger.debug(`URL blocked: invalid URL format`); + return true; + } + } + + /** + * Validate a URL for use in HTTP hooks + * @param url - The URL to validate + * @returns Validation result with allowed status and reason + */ + validate(url: string): { allowed: boolean; reason?: string } { + // First check if blocked for security + if (this.isBlocked(url)) { + return { + allowed: false, + reason: 'URL is blocked for security reasons (SSRF protection)', + }; + } + + // Then check whitelist + if (!this.isAllowed(url)) { + return { + allowed: false, + reason: `URL does not match any allowed pattern. Allowed patterns: ${this.allowedPatterns.join(', ')}`, + }; + } + + return { allowed: true }; + } + + /** + * Check if a string is an IP address (IPv4 or IPv6) + * Uses Node.js net module for accurate validation of all IP formats + * including ::1, ::ffff:192.168.1.1, 2001:db8::1, etc. + */ + private isIpAddress(hostname: string): boolean { + // Remove brackets from IPv6 addresses (e.g., [::1] -> ::1) + const cleanHostname = hostname.replace(/^\[|\]$/g, ''); + return isIPv4(cleanHostname) || isIPv6(cleanHostname); + } +} + +/** + * Create a URL validator from configuration + * @param allowedUrls - Array of allowed URL patterns from config + * @returns Configured URL validator + */ +export function createUrlValidator(allowedUrls?: string[]): UrlValidator { + return new UrlValidator(allowedUrls || []); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a806112cc..adb990999 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -282,7 +282,7 @@ export * from './test-utils/index.js'; export * from './hooks/types.js'; export { HookSystem, HookRegistry } from './hooks/index.js'; -export type { HookRegistryEntry } from './hooks/index.js'; +export type { HookRegistryEntry, SessionHookEntry } from './hooks/index.js'; export { type StopFailureErrorType } from './hooks/types.js'; // Export hook triggers for all hook events diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index 700e70273..2a74087e2 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -8,6 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; +import * as yaml from 'yaml'; import { SkillManager } from './skill-manager.js'; import { type SkillConfig, SkillError } from './types.js'; import type { Config } from '../config/config.js'; @@ -20,6 +21,8 @@ vi.mock('os'); // Mock yaml parser - use vi.hoisted for proper hoisting const mockParseYaml = vi.hoisted(() => vi.fn()); +// Only mock yaml-parser for non-hooks tests +// For hooks tests, we'll use the real parser by unmocking selectively vi.mock('../utils/yaml-parser.js', () => ({ parse: mockParseYaml, stringify: vi.fn(), @@ -45,6 +48,10 @@ describe('SkillManager', () => { // Setup yaml parser mocks with sophisticated behavior mockParseYaml.mockImplementation((yamlString: string) => { // Handle different test cases based on YAML content + if (yamlString.includes('hooks:')) { + // For hooks tests, use real YAML parser + return yaml.parse(yamlString); + } if (yamlString.includes('allowedTools:')) { return { name: 'test-skill', @@ -894,4 +901,146 @@ Symlinked skill content`); ]); }); }); + + describe('hooks parsing', () => { + it('should parse hooks configuration from frontmatter', () => { + const markdown = `--- +name: hook-skill +description: Skill with hooks +hooks: + PreToolUse: + - matcher: "Bash" + hooks: + - type: command + command: 'echo "checking"' + timeout: 5 +--- +Skill content`; + + const config = manager.parseSkillContent( + markdown, + '/test/skill/SKILL.md', + 'user', + ); + + expect(config.hooks).toBeDefined(); + expect(config.hooks?.PreToolUse).toBeDefined(); + expect(config.hooks?.PreToolUse).toHaveLength(1); + expect(config.hooks?.PreToolUse?.[0]?.matcher).toBe('Bash'); + expect(config.hooks?.PreToolUse?.[0]?.hooks).toHaveLength(1); + }); + + it('should parse multiple hooks for same event', () => { + const markdown = `--- +name: multi-hook-skill +description: Skill with multiple hooks +hooks: + PreToolUse: + - matcher: "Bash" + hooks: + - type: command + command: 'echo "first"' + - type: command + command: 'echo "second"' + - matcher: "Write" + hooks: + - type: http + url: 'https://example.com/hook' +--- +Skill content`; + + const config = manager.parseSkillContent( + markdown, + '/test/skill/SKILL.md', + 'user', + ); + + expect(config.hooks?.PreToolUse).toHaveLength(2); + expect(config.hooks?.PreToolUse?.[0]?.hooks).toHaveLength(2); + expect(config.hooks?.PreToolUse?.[1]?.matcher).toBe('Write'); + }); + + it('should parse HTTP hooks with headers', () => { + const markdown = `--- +name: http-hook-skill +description: Skill with HTTP hooks +hooks: + PostToolUse: + - matcher: "*" + hooks: + - type: http + url: 'https://audit.example.com/log' + headers: + Authorization: 'Bearer token' + allowedEnvVars: + - API_KEY + timeout: 10 +--- +Skill content`; + + const config = manager.parseSkillContent( + markdown, + '/test/skill/SKILL.md', + 'user', + ); + + expect(config.hooks?.PostToolUse).toHaveLength(1); + const hook = config.hooks?.PostToolUse?.[0]?.hooks?.[0]; + expect(hook?.type).toBe('http'); + if (hook?.type === 'http') { + expect(hook.url).toBe('https://audit.example.com/log'); + expect(hook.headers).toEqual({ Authorization: 'Bearer token' }); + expect(hook.allowedEnvVars).toEqual(['API_KEY']); + expect(hook.timeout).toBe(10); + } + }); + + it('should ignore unknown hook events', () => { + const markdown = `--- +name: unknown-event-skill +description: Skill with unknown event +hooks: + UnknownEvent: + - matcher: "*" + hooks: + - type: command + command: 'echo "test"' +--- +Skill content`; + + const config = manager.parseSkillContent( + markdown, + '/test/skill/SKILL.md', + 'user', + ); + + // Unknown events are ignored, only valid HookEventNames are kept + expect(config.hooks).toBeDefined(); + // UnknownEvent should not be in the parsed hooks + expect(Object.keys(config.hooks || {})).not.toContain('UnknownEvent'); + }); + + it('should set skillRoot from filePath', () => { + const markdown = `--- +name: skillroot-skill +description: Skill with skillRoot +hooks: + PreToolUse: + - matcher: "Bash" + hooks: + - type: command + command: 'echo $QWEN_SKILL_ROOT' +--- +Skill content`; + + const config = manager.parseSkillContent( + markdown, + '/test/skill/SKILL.md', + 'user', + ); + + // skillRoot should be set to the directory containing SKILL.md + expect(config.skillRoot).toBe('/test/skill'); + }); + }); }); diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 3ebf2945b..89aafa87d 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -11,11 +11,13 @@ import * as os from 'os'; import { fileURLToPath } from 'url'; import { watch as watchFs, type FSWatcher } from 'chokidar'; import { parse as parseYaml } from '../utils/yaml-parser.js'; +import * as yaml from 'yaml'; import type { SkillConfig, SkillLevel, ListSkillsOptions, SkillValidationResult, + SkillHooksSettings, } from './types.js'; import { SkillError, SkillErrorCode, parseModelField } from './types.js'; import type { Config } from '../config/config.js'; @@ -23,6 +25,13 @@ import { validateConfig } from './skill-load.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { normalizeContent } from '../utils/textUtils.js'; import { SKILL_PROVIDER_CONFIG_DIRS } from '../config/storage.js'; +import { + HookEventName, + HookType, + type HookDefinition, + type CommandHookConfig, + type HttpHookConfig, +} from '../hooks/types.js'; const debugLogger = createDebugLogger('SKILL_MANAGER'); @@ -396,6 +405,25 @@ export class SkillManager { } } + // Extract hooks configuration + // Use full YAML parser for hooks as they have nested structures + let hooks: SkillHooksSettings | undefined; + if (frontmatterYaml.includes('hooks:')) { + // Re-parse with full YAML parser to get nested hooks structure + const fullFrontmatter = yaml.parse(frontmatterYaml) as Record< + string, + unknown + >; + const hooksRaw = fullFrontmatter['hooks'] as + | Record + | undefined; + if (hooksRaw !== undefined) { + hooks = this.parseHooksConfig(hooksRaw); + } + } + + // Set skillRoot to the directory containing SKILL.md + const skillRoot = path.dirname(filePath); // Extract optional model field const model = parseModelField(frontmatter); @@ -403,6 +431,8 @@ export class SkillManager { name, description, allowedTools, + hooks, + skillRoot, model, level, filePath, @@ -429,6 +459,116 @@ export class SkillManager { } } + /** + * Parses hooks configuration from frontmatter. + * + * @param hooksRaw - Raw hooks object from frontmatter + * @returns Parsed SkillHooksSettings + */ + private parseHooksConfig( + hooksRaw: Record, + ): SkillHooksSettings { + const hooks: SkillHooksSettings = {}; + + // Get valid hook event names + const validEvents = Object.values(HookEventName); + + for (const [eventName, matchersRaw] of Object.entries(hooksRaw)) { + // Validate event name + if (!validEvents.includes(eventName as HookEventName)) { + debugLogger.warn(`Unknown hook event: ${eventName}, skipping`); + continue; + } + + // Parse matchers array + if (!Array.isArray(matchersRaw)) { + debugLogger.warn(`Hooks for ${eventName} must be an array, skipping`); + continue; + } + + const matchers: HookDefinition[] = []; + for (const matcherRaw of matchersRaw) { + if (typeof matcherRaw !== 'object' || matcherRaw === null) { + debugLogger.warn(`Invalid matcher in ${eventName}, skipping`); + continue; + } + + const matcher = matcherRaw as Record; + const hookDef = this.parseHookMatcher(matcher); + if (hookDef) { + matchers.push(hookDef); + } + } + + if (matchers.length > 0) { + hooks[eventName as HookEventName] = matchers; + } + } + + return hooks; + } + + /** + * Parses a single hook matcher configuration. + * + * @param matcher - Raw matcher object + * @returns HookDefinition or null if invalid + */ + private parseHookMatcher( + matcher: Record, + ): HookDefinition | null { + const matcherPattern = matcher['matcher'] as string | undefined; + const hooksRaw = matcher['hooks'] as unknown[] | undefined; + + if (!hooksRaw || !Array.isArray(hooksRaw)) { + debugLogger.warn('Matcher missing hooks array, skipping'); + return null; + } + + const hooks: Array = []; + + for (const hookRaw of hooksRaw) { + if (typeof hookRaw !== 'object' || hookRaw === null) { + continue; + } + + const hook = hookRaw as Record; + const hookType = hook['type'] as string; + + if (hookType === 'command') { + const commandHook: CommandHookConfig = { + type: HookType.Command, + command: hook['command'] as string, + timeout: hook['timeout'] as number | undefined, + statusMessage: hook['statusMessage'] as string | undefined, + shell: hook['shell'] as 'bash' | 'powershell' | undefined, + }; + hooks.push(commandHook); + } else if (hookType === 'http') { + const httpHook: HttpHookConfig = { + type: HookType.Http, + url: hook['url'] as string, + headers: hook['headers'] as Record | undefined, + allowedEnvVars: hook['allowedEnvVars'] as string[] | undefined, + timeout: hook['timeout'] as number | undefined, + statusMessage: hook['statusMessage'] as string | undefined, + }; + hooks.push(httpHook); + } else { + debugLogger.warn(`Unknown hook type: ${hookType}, skipping`); + } + } + + if (hooks.length === 0) { + return null; + } + + return { + matcher: matcherPattern, + hooks, + }; + } + /** * Gets the base directory for skills at a specific level. * diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts index c1a378c80..c7afcf3ff 100644 --- a/packages/core/src/skills/types.ts +++ b/packages/core/src/skills/types.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { HookDefinition, HookEventName } from '../hooks/types.js'; + /** * Represents the storage level for a skill configuration. * - 'project': Stored in `.qwen/skills/` within the project directory @@ -13,6 +15,14 @@ */ export type SkillLevel = 'project' | 'user' | 'extension' | 'bundled'; +/** + * Hooks configuration for a skill. + * Maps hook event names to hook definitions. + */ +export type SkillHooksSettings = Partial< + Record +>; + /** * Core configuration for a skill as stored in SKILL.md files. * Each skill directory contains a SKILL.md file with YAML frontmatter @@ -31,6 +41,12 @@ export interface SkillConfig { */ allowedTools?: string[]; + /** + * Hooks to register when this skill is invoked. + * Hooks are registered as session-scoped hooks that persist + * for the duration of the session. + */ + hooks?: SkillHooksSettings; /** * Optional model override for this skill's execution. * Uses the same selector syntax as subagent model selectors: @@ -49,6 +65,12 @@ export interface SkillConfig { */ filePath: string; + /** + * Absolute path to the skill root directory (directory containing SKILL.md). + * Used to set QWEN_SKILL_ROOT environment variable for skill hooks. + */ + skillRoot?: string; + /** * The markdown body content from SKILL.md (after the frontmatter) */ diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 575e4c1b1..5ed29539b 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -802,6 +802,9 @@ export class AuthEvent implements BaseTelemetryEvent { } } +/** Hook type for telemetry */ +export type HookTelemetryType = 'command' | 'http' | 'function'; + /** * Hook call telemetry event */ @@ -809,7 +812,7 @@ export class HookCallEvent implements BaseTelemetryEvent { 'event.name': string; 'event.timestamp': string; hook_event_name: string; - hook_type: 'command'; + hook_type: HookTelemetryType; hook_name: string; hook_input: Record; hook_output?: Record; @@ -822,7 +825,7 @@ export class HookCallEvent implements BaseTelemetryEvent { constructor( hookEventName: string, - hookType: 'command', + hookType: HookTelemetryType, hookName: string, hookInput: Record, durationMs: number, diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index 7f6faa1ff..ab47beabe 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -13,6 +13,7 @@ import type { SkillConfig } from '../skills/types.js'; import { logSkillLaunch, SkillLaunchEvent } from '../telemetry/index.js'; import path from 'path'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { registerSkillHooks } from '../hooks/registerSkillHooks.js'; const debugLogger = createDebugLogger('SKILL'); @@ -275,6 +276,42 @@ class SkillToolInvocation extends BaseToolInvocation { ); this.onSkillLoaded(this.params.skill); + // Register skill hooks if present + debugLogger.debug('Skill hooks check:', { + hasHooks: !!skill.hooks, + hooksKeys: skill.hooks ? Object.keys(skill.hooks) : [], + skillName: skill.name, + }); + if (skill.hooks) { + const hookSystem = this.config.getHookSystem(); + const sessionId = this.config.getSessionId(); + debugLogger.debug('Hook system and session:', { + hasHookSystem: !!hookSystem, + sessionId, + }); + if (hookSystem && sessionId) { + const sessionHooksManager = hookSystem.getSessionHooksManager(); + const hookCount = registerSkillHooks( + sessionHooksManager, + sessionId, + skill, + ); + if (hookCount > 0) { + debugLogger.info( + `Registered ${hookCount} hooks from skill "${this.params.skill}"`, + ); + } else { + debugLogger.warn( + `No hooks registered from skill "${this.params.skill}"`, + ); + } + } + } else { + debugLogger.warn( + `Skill "${this.params.skill}" has no hooks to register`, + ); + } + const baseDir = path.dirname(skill.filePath); const llmContent = buildSkillLlmContent(baseDir, skill.body); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index cc7b3ce43..084ea6495 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -609,6 +609,14 @@ "type": "string" } } + }, + "allowedHttpHookUrls": { + "description": "Whitelist of URL patterns for HTTP hooks. Supports * wildcard. If empty, all URLs are allowed (subject to SSRF protection).", + "type": "array", + "items": { + "description": "URL pattern (supports * wildcard)", + "type": "string" + } } } }, @@ -729,20 +737,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -752,7 +779,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -761,11 +788,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } } @@ -794,20 +840,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -817,7 +882,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -826,11 +891,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } } @@ -859,20 +943,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -882,7 +985,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -891,11 +994,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } } @@ -924,20 +1046,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -947,7 +1088,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -956,11 +1097,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } } @@ -989,20 +1149,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -1012,7 +1191,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -1021,11 +1200,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } } @@ -1054,20 +1252,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -1077,7 +1294,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -1086,11 +1303,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } } @@ -1119,20 +1355,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -1142,7 +1397,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -1151,11 +1406,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } } @@ -1184,20 +1458,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -1207,7 +1500,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -1216,11 +1509,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } } @@ -1249,20 +1561,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -1272,7 +1603,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -1281,11 +1612,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } } @@ -1314,20 +1664,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -1337,7 +1706,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -1346,11 +1715,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } } @@ -1379,20 +1767,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -1402,7 +1809,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -1411,11 +1818,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } } @@ -1444,20 +1870,39 @@ "description": "The list of hook configurations to execute.", "type": "array", "items": { - "description": "A hook configuration entry that defines a command to execute.", + "description": "A hook configuration entry that defines a hook to execute.", "type": "object", "properties": { "type": { - "description": "The type of hook.", + "description": "The type of hook. Note: \"function\" type is only available via SDK registration, not settings.json.", "type": "string", "enum": [ - "command" + "command", + "http" ] }, "command": { - "description": "The command to execute when the hook is triggered.", + "description": "The command to execute when the hook is triggered. Required for \"command\" type.", "type": "string" }, + "url": { + "description": "The URL to send the POST request to. Required for \"http\" type.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedEnvVars": { + "description": "List of environment variables allowed for interpolation in headers and URL.", + "type": "array", + "items": { + "type": "string" + } + }, "name": { "description": "An optional name for the hook.", "type": "string" @@ -1467,7 +1912,7 @@ "type": "string" }, "timeout": { - "description": "Timeout in milliseconds for the hook execution.", + "description": "Timeout in seconds for the hook execution.", "type": "number" }, "env": { @@ -1476,11 +1921,30 @@ "additionalProperties": { "type": "string" } + }, + "async": { + "description": "Whether to execute the hook asynchronously (non-blocking, for \"command\" type only).", + "type": "boolean" + }, + "once": { + "description": "Whether to execute the hook only once per session (for \"http\" type).", + "type": "boolean" + }, + "statusMessage": { + "description": "A message to display while the hook is executing.", + "type": "string" + }, + "shell": { + "description": "The shell to use for command execution.", + "type": "string", + "enum": [ + "bash", + "powershell" + ] } }, "required": [ - "type", - "command" + "type" ] } }