Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
432 changes: 314 additions & 118 deletions docs/users/features/hooks.md

Large diffs are not rendered by default.

1,139 changes: 1,139 additions & 0 deletions integration-tests/hook-integration/hooks-advanced.test.ts

Large diffs are not rendered by default.

254 changes: 254 additions & 0 deletions integration-tests/hook-integration/mockHttpServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import {
createServer,
type Server,
type IncomingMessage,
type ServerResponse,
} from 'http';

/**
* Hook output type for HTTP hook responses
*/
export interface HookOutput {
continue?: boolean;
stopReason?: string;
suppressOutput?: boolean;
systemMessage?: string;
decision?: 'ask' | 'block' | 'deny' | 'approve' | 'allow';
reason?: string;
hookSpecificOutput?: Record<string, unknown>;
}

/**
* Mock HTTP Server for testing HTTP hooks
* Provides endpoints that simulate various hook response scenarios
*/
export class MockHttpServer {
private server: Server | null = null;
private port: number = 0;
private readonly responses: Map<
string,
HookOutput | ((input: Record<string, unknown>) => HookOutput)
> = new Map();
private readonly requestLogs: Array<{
url: string;
body: Record<string, unknown>;
timestamp: number;
}> = [];

/**
* Start the mock server on a random available port
*/
async start(): Promise<number> {
return new Promise((resolve, reject) => {
this.server = createServer((req, res) => {
this.handleRequest(req, res);
});

this.server.listen(0, () => {
const address = this.server!.address();
if (address && typeof address === 'object') {
this.port = address.port;
resolve(this.port);
} else {
reject(new Error('Failed to get server port'));
}
});

this.server.on('error', reject);
});
}

/**
* Stop the mock server
*/
async stop(): Promise<void> {
return new Promise((resolve) => {
if (this.server) {
this.server.close(() => {
this.server = null;
resolve();
});
} else {
resolve();
}
});
}

/**
* Get the server's base URL
*/
getUrl(): string {
return `http://127.0.0.1:${this.port}`;
}

/**
* Set response for a specific path
*/
setResponse(
path: string,
response: HookOutput | ((input: Record<string, unknown>) => HookOutput),
): void {
this.responses.set(path, response);
}

/**
* Get all received request logs
*/
getRequestLogs(): Array<{
url: string;
body: Record<string, unknown>;
timestamp: number;
}> {
return [...this.requestLogs];
}

/**
* Clear request logs
*/
clearRequestLogs(): void {
this.requestLogs.length = 0;
}

/**
* Handle incoming HTTP request
*/
private handleRequest(req: IncomingMessage, res: ServerResponse): void {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});

req.on('end', () => {
const parsedBody = JSON.parse(body || '{}');

// Log the request
this.requestLogs.push({
url: req.url || '/',
body: parsedBody,
timestamp: Date.now(),
});

// Find matching response
const response = this.responses.get(req.url || '/');

if (response) {
const output =
typeof response === 'function' ? response(parsedBody) : response;

res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(output));
} else {
// Default response: allow with continue
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ continue: true }));
}
});

req.on('error', (err) => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
}
}

/**
* Pre-defined response scenarios for HTTP hook testing
*/
export const HttpHookResponses = {
/** Allow execution */
allow: { decision: 'allow', continue: true } as HookOutput,

/** Block execution */
block: {
decision: 'block',
reason: 'Blocked by HTTP hook',
continue: false,
} as HookOutput,

/** Ask for permission */
ask: { decision: 'ask', reason: 'User confirmation required' } as HookOutput,

/** Deny execution */
deny: { decision: 'deny', reason: 'Denied by HTTP hook' } as HookOutput,

/** Return additional context */
withContext: (context: string): HookOutput => ({
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: context,
},
}),

/** Return system message */
withSystemMessage: (message: string): HookOutput => ({
continue: true,
systemMessage: message,
}),

/** PreToolUse allow with permission decision */
preToolUseAllow: {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
permissionDecisionReason: 'Tool execution approved by HTTP hook',
},
} as HookOutput,

/** PreToolUse deny with permission decision */
preToolUseDeny: {
continue: false,
decision: 'deny',
reason: 'Tool execution denied by HTTP hook',
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: 'Security policy violation',
},
} as HookOutput,

/** PreToolUse ask for confirmation */
preToolUseAsk: {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'ask',
permissionDecisionReason: 'Requires user confirmation',
},
} as HookOutput,

/** UserPromptSubmit with additional context */
userPromptSubmitContext: (context: string): HookOutput => ({
continue: true,
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext: context,
},
}),

/** PostToolUse with additional context */
postToolUseContext: (context: string): HookOutput => ({
continue: true,
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: context,
},
}),

/** Stop hook with stop reason */
stopWithReason: (reason: string): HookOutput => ({
continue: true,
stopReason: reason,
hookSpecificOutput: {
hookEventName: 'Stop',
additionalContext: `Stop reason: ${reason}`,
},
}),
};
12 changes: 11 additions & 1 deletion packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,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;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/auth/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand All @@ -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).
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

/**
Expand Down
Loading
Loading