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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>
This commit is contained in:
DennisYu07 2026-04-16 10:10:33 +08:00 committed by GitHub
parent 70396d1276
commit b5115e731e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 9301 additions and 469 deletions

View file

@ -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;
}

View file

@ -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') {

View file

@ -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<string, unknown>;
projectHooks?: Record<string, unknown>;
},
): Promise<Config> {
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).

View file

@ -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<string, unknown> | 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<string, unknown> | undefined {
// Only return project hooks if workspace is trusted
if (!this.isTrusted) {
return undefined;
}
return this.workspace.settings.hooks;
}
}
/**

View file

@ -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)',
},
},
},
},

View file

@ -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';

View file

@ -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');

View file

@ -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',

View file

@ -435,6 +435,7 @@ export default {
'User Settings': 'ユーザー設定',
'System Settings': 'システム設定',
Extensions: '拡張機能',
'Session (temporary)': 'セッション(一時)',
// Hooks - Status
'✓ Enabled': '✓ 有効',
'✗ Disabled': '✗ 無効',

View file

@ -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',

View file

@ -662,6 +662,7 @@ export default {
'User Settings': 'Пользовательские настройки',
'System Settings': 'Системные настройки',
Extensions: 'Расширения',
'Session (temporary)': 'Сессия (временно)',
// Hooks - Status
'✓ Enabled': '✓ Включен',
'✗ Disabled': '✗ Отключен',

View file

@ -687,6 +687,7 @@ export default {
'User Settings': '用户设置',
'System Settings': '系统设置',
Extensions: '扩展',
'Session (temporary)': '会话(临时)',
// Hooks - Status
'✓ Enabled': '✓ 已启用',
'✗ Disabled': '✗ 已禁用',

View file

@ -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');
});
});
});

View file

@ -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<string, HookRegistryEntry[]>();
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';
}

View file

@ -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 (
<Box key={index}>
{/* Left column: selector + command */}
{/* Left column: selector + display */}
<Box width={commandWidth}>
<Box minWidth={2}>
<Text
@ -108,7 +128,7 @@ export function HookDetailStep({
bold={isSelected}
wrap="wrap"
>
{`${index + 1}. [${hookType}] ${command}`}
{`${index + 1}. [${typeDisplay}] ${hookDisplay}`}
</Text>
</Box>
{/* Spacer between columns */}

View file

@ -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(
<HooksManagementDialog onClose={mockOnClose} />,
);
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', () => {

View file

@ -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<string, unknown>;
// 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);
}

View file

@ -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)'),
};
}

View file

@ -37,6 +37,7 @@ export interface HookConfigDisplayInfo {
source: HooksConfigSource;
sourceDisplay: string;
sourcePath?: string;
matcher?: string;
enabled: boolean;
}

View file

@ -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<string, unknown>;
/**
* 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<string, unknown>;
hooks?: Record<string, unknown>;
/** 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<string, unknown>;
/** Project-level hooks (only loaded in trusted folders) */
private readonly projectHooks?: Record<string, unknown>;
/** @deprecated Legacy merged hooks field - use userHooks/projectHooks instead */
private readonly hooks?: Record<string, unknown>;
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<string, unknown> | 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<string, unknown> | 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

View file

@ -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 });

View file

@ -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`);
});
});
});

View file

@ -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"',
);
});
});

View file

@ -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();
});
});
});

View file

@ -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<string, PendingAsyncHook> = new Map();
private readonly completedOutputs: AsyncHookOutputMessage[] = [];
private readonly completedContexts: string[] = [];
private readonly maxConcurrentHooks: number;
private timeoutCheckTimer: ReturnType<typeof setInterval> | 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<PendingAsyncHook, 'status'>): 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(),
});
}
}
}

View file

@ -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();
});
});

View file

@ -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<typeof setTimeout> | 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 };
}

View file

@ -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('');
});
});
});

View file

@ -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<string, string>,
allowedVars: string[],
): Record<string, string> {
const result: Record<string, string> = {};
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;
}

View file

@ -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> = {}): 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> = {},
): 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);
});
});
});

View file

@ -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<HookExecutionResult> {
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<HookOutput | boolean | undefined> {
// Validate callback
if (typeof callback !== 'function') {
throw new Error('Invalid callback: expected a function');
}
let timeoutId: ReturnType<typeof setTimeout> | 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<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Function hook timed out after ${timeout}ms`));
}, timeout);
});
// Create abort promise
const abortPromise = new Promise<never>((_, 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<Promise<HookOutput | boolean | undefined | never>> =
[callback(input, context), timeoutPromise];
if (signal) {
promises.push(abortPromise);
}
const result = await Promise.race(promises);
cleanup();
return result;
} catch (error) {
cleanup();
throw error;
}
}
}

View file

@ -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',
}),
);
});
});
});

View file

@ -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<AggregatedHookResult> {
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;
}
}

View file

@ -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,

View file

@ -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();

View file

@ -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;
}

View file

@ -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);
});
});
});

View file

@ -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<HookExecutionResult> {
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<HookExecutionResult> {
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<void> {
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<HookExecutionResult[]> {
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<HookExecutionResult[]> {
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,

View file

@ -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();
});
});
});

View file

@ -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);
}
}

View file

@ -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> = {}): 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> = {},
): 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);
});
});
});

View file

@ -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<string> = 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<HookExecutionResult> {
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<HookOutput> {
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<string, unknown>,
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<string, unknown>;
// 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);
}
}

View file

@ -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';

View file

@ -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);
});
});

View file

@ -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;
}

View file

@ -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
});
});
});

View file

@ -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<HookEventName, SessionHookEntry[]>;
}
/**
* 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<string, SessionHooksStorage> = 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;
}
}

View file

@ -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
});
});
});

View file

@ -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:<v4> 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<string>(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,
});
}

View file

@ -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);
}
}
}

View file

@ -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<string, string>;
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<string, string>;
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<Record<string, unknown>>;
/** 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<HookOutput | boolean | undefined>;
/**
* 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<Record<string, unknown>> | 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[];
}

View file

@ -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);
});
});
});

View file

@ -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 || []);
}

View file

@ -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

View file

@ -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');
});
});
});

View file

@ -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<string, unknown>
| 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<string, unknown>,
): 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<string, unknown>;
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<string, unknown>,
): 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<CommandHookConfig | HttpHookConfig> = [];
for (const hookRaw of hooksRaw) {
if (typeof hookRaw !== 'object' || hookRaw === null) {
continue;
}
const hook = hookRaw as Record<string, unknown>;
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<string, string> | 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.
*

View file

@ -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<HookEventName, HookDefinition[]>
>;
/**
* 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)
*/

View file

@ -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<string, unknown>;
hook_output?: Record<string, unknown>;
@ -822,7 +825,7 @@ export class HookCallEvent implements BaseTelemetryEvent {
constructor(
hookEventName: string,
hookType: 'command',
hookType: HookTelemetryType,
hookName: string,
hookInput: Record<string, unknown>,
durationMs: number,

View file

@ -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<SkillParams, ToolResult> {
);
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);

File diff suppressed because it is too large Load diff