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

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