fix(qwen): mirror Qwen Code 0.20 hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 15:10:44 -04:00
1 parent aa4e2c5fd4
commit 3095661c9a
12 files changed
+1877 -1208

No files matched your search

+249 -294
View File
@@ -1,352 +1,289 @@
import os from "node:os";
import path from "node:path";
import {
buildQwenCommand,
qwenRuntimeReference,
sameQwenAgentId,
sameQwenPath,
type QwenRuntimeReference,
} from './qwen-command.js';
import { isObject, type JsonObject } from './strict-json.js';
export const AGENT_KEY = "qwen";
export const GUARD_SCRIPT = "guard.js";
export const AUDIT_SCRIPT = "hook.js";
export const AGENT_KEY = 'qwen';
export const GUARD_SCRIPT = 'guard.js';
export const AUDIT_SCRIPT = 'hook.js';
export const GUARD_HOOK_NAME = 'elydora-guard';
export const AUDIT_HOOK_NAME = 'elydora-audit';
export const HOOK_TIMEOUT_MS = 10_000;
export const TOOL_EVENTS = ["PreToolUse", "PostToolUse"] as const;
export const MANAGED_EVENTS = [
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
] as const;
const EVENT_NAMES = new Set([
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PostToolBatch",
"Notification",
"UserPromptSubmit",
"UserPromptExpansion",
"SessionStart",
"Stop",
"MessageDisplay",
"SubagentStart",
"SubagentStop",
"PreCompact",
"PostCompact",
"SessionEnd",
"PermissionRequest",
"PermissionDenied",
"StopFailure",
"TodoCreated",
"TodoCompleted",
"InstructionsLoaded",
const KNOWN_EVENTS = new Set([
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PostToolBatch',
'Notification',
'UserPromptSubmit',
'UserPromptExpansion',
'SessionStart',
'Stop',
'MessageDisplay',
'SubagentStart',
'SubagentStop',
'PreCompact',
'PostCompact',
'SessionEnd',
'PermissionRequest',
'PermissionDenied',
'StopFailure',
'TodoCreated',
'TodoCompleted',
'InstructionsLoaded',
]);
const CONFIG_FIELDS = new Set(["enabled", "disabled", "notifications"]);
const HANDLER_KEYS = ["command", "shell", "timeout", "type"];
const GROUP_KEYS = ["hooks", "matcher"];
export type JsonObject = Record<string, unknown>;
export type ToolEvent = (typeof TOOL_EVENTS)[number];
const REGEX_MATCHER_EVENTS = new Set([
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PermissionRequest',
'PermissionDenied',
'SubagentStart',
'SubagentStop',
'PreCompact',
'PostCompact',
'SessionStart',
'SessionEnd',
'StopFailure',
'Notification',
'InstructionsLoaded',
'UserPromptExpansion',
]);
export interface QwenCommandHandler extends JsonObject {
readonly type: "command";
readonly command: string;
export type ManagedQwenEvent = (typeof MANAGED_EVENTS)[number];
export interface QwenHandler extends JsonObject {
readonly type: 'command' | 'http' | 'prompt';
readonly command?: string;
readonly name?: string;
readonly shell?: 'bash' | 'powershell';
readonly timeout?: number;
readonly shell?: "bash" | "powershell";
}
export interface QwenGroup extends JsonObject {
readonly matcher?: string;
readonly sequential?: boolean;
readonly hooks: JsonObject[];
readonly hooks: QwenHandler[];
}
export interface QwenHookSettings extends JsonObject {
readonly PreToolUse?: QwenGroup[];
readonly PostToolUse?: QwenGroup[];
}
export type QwenHooks = Record<string, QwenGroup[]>;
export interface RuntimeContract {
export interface QwenRuntimeContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface ManagedRemoval {
readonly event: ToolEvent;
readonly event: ManagedQwenEvent;
readonly groupIndex: number;
readonly handlerIndexes: number[];
readonly handlerIndexes: readonly number[];
readonly removeGroup: boolean;
}
interface ParsedArgument {
readonly value: string;
readonly next: number;
}
export function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
export function buildCommand(scriptPath: string): string {
if (process.platform === "win32") {
return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
}
return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
}
export function buildGroup(scriptPath: string): QwenGroup {
return {
matcher: "*",
hooks: [
{
type: "command",
command: buildCommand(scriptPath),
shell: process.platform === "win32" ? "powershell" : "bash",
timeout: HOOK_TIMEOUT_MS,
},
],
};
}
function validateRegex(value: string, label: string): void {
if (value.trim() === "" || value.trim() === "*") return;
try {
new RegExp(value);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`${label} must be a valid regular expression: ${message}`);
function optionalString(value: JsonObject, field: string, label: string): void {
if (value[field] !== undefined && typeof value[field] !== 'string') {
throw new Error(`${label} field "${field}" must be a string`);
}
}
function optionalString(value: JsonObject, key: string, label: string): void {
if (value[key] !== undefined && typeof value[key] !== "string") {
throw new Error(`${label} field "${key}" must be a string`);
function optionalBoolean(value: JsonObject, field: string, label: string): void {
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
throw new Error(`${label} field "${field}" must be a boolean`);
}
}
function optionalBoolean(value: JsonObject, key: string, label: string): void {
if (value[key] !== undefined && typeof value[key] !== "boolean") {
throw new Error(`${label} field "${key}" must be a boolean`);
}
}
function optionalStringMap(
value: JsonObject,
key: string,
label: string,
): void {
const item = value[key];
function optionalStringMap(value: JsonObject, field: string, label: string): void {
const item = value[field];
if (item === undefined) return;
if (
!isObject(item) ||
Object.values(item).some((entry) => typeof entry !== "string")
) {
throw new Error(`${label} field "${key}" must contain string values`);
if (!isObject(item) || Object.values(item).some((entry) => typeof entry !== 'string')) {
throw new Error(`${label} field "${field}" must map names to strings`);
}
}
function validateHandler(value: unknown, label: string): JsonObject {
if (!isObject(value)) throw new Error(`${label} must be an object`);
if (
value.type !== "command" &&
value.type !== "http" &&
value.type !== "prompt"
) {
throw new Error(`${label} type must be "command", "http", or "prompt"`);
}
if (
value.timeout !== undefined &&
(typeof value.timeout !== "number" ||
!Number.isFinite(value.timeout) ||
value.timeout < 0)
) {
function validateTimeout(value: unknown, label: string): void {
if (value === undefined) return;
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw new Error(`${label} timeout must be a non-negative finite number`);
}
for (const key of ["name", "description", "statusMessage", "source"]) {
optionalString(value, key, label);
}
function validateHandler(value: unknown, label: string): QwenHandler {
if (!isObject(value)) throw new Error(`${label} must be an object`);
if (!['command', 'http', 'prompt'].includes(String(value.type))) {
throw new Error(`${label} has unsupported type "${String(value.type)}"`);
}
if (value.type === "command") {
if (typeof value.command !== "string" || value.command.length === 0) {
throw new Error(`${label} command must be a non-empty string`);
validateTimeout(value.timeout, label);
for (const field of ['name', 'description', 'statusMessage', 'source']) {
optionalString(value, field, label);
}
if (value.type === 'command') {
if (typeof value.command !== 'string' || value.command.length === 0) {
throw new Error(`${label} requires a non-empty command`);
}
optionalStringMap(value, "env", label);
optionalBoolean(value, "async", label);
if (
value.shell !== undefined &&
value.shell !== "bash" &&
value.shell !== "powershell"
) {
optionalStringMap(value, 'env', label);
optionalBoolean(value, 'async', label);
if (value.shell !== undefined && !['bash', 'powershell'].includes(String(value.shell))) {
throw new Error(`${label} shell must be "bash" or "powershell"`);
}
}
if (value.type === "http") {
if (typeof value.url !== "string" || value.url.length === 0) {
throw new Error(`${label} url must be a non-empty string`);
if (value.type === 'http') {
if (typeof value.url !== 'string' || value.url.length === 0) {
throw new Error(`${label} requires a non-empty url`);
}
optionalStringMap(value, "headers", label);
optionalBoolean(value, "once", label);
optionalString(value, "if", label);
if (
value.allowedEnvVars !== undefined &&
(!Array.isArray(value.allowedEnvVars) ||
value.allowedEnvVars.some((item) => typeof item !== "string"))
) {
optionalStringMap(value, 'headers', label);
optionalBoolean(value, 'once', label);
optionalString(value, 'if', label);
if (value.allowedEnvVars !== undefined && (
!Array.isArray(value.allowedEnvVars)
|| value.allowedEnvVars.some((item) => typeof item !== 'string')
)) {
throw new Error(`${label} allowedEnvVars must be an array of strings`);
}
}
if (value.type === "prompt") {
if (typeof value.prompt !== "string" || value.prompt.length === 0) {
throw new Error(`${label} prompt must be a non-empty string`);
if (value.type === 'prompt') {
if (typeof value.prompt !== 'string' || value.prompt.length === 0) {
throw new Error(`${label} requires a non-empty prompt`);
}
optionalString(value, "model", label);
optionalString(value, 'model', label);
}
return value;
return value as QwenHandler;
}
function validateGroup(
value: unknown,
label: string,
index: number,
): QwenGroup {
const location = `${label}[${index}]`;
if (!isObject(value)) throw new Error(`${location} must be an object`);
function validateMatcher(matcher: string, event: string, label: string): void {
if (!REGEX_MATCHER_EVENTS.has(event) || matcher.trim() === '' || matcher.trim() === '*') {
return;
}
try {
new RegExp(matcher);
} catch (error) {
throw new Error(`${label} matcher must be a valid regular expression`, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
}
function validateGroup(value: unknown, event: string, index: number): QwenGroup {
const label = `Qwen Code settings group hooks.${event}[${index}]`;
if (!isObject(value)) throw new Error(`${label} must be an object`);
if (value.matcher !== undefined) {
if (typeof value.matcher !== "string")
throw new Error(`${location} matcher must be a string`);
validateRegex(value.matcher, `${location} matcher`);
if (typeof value.matcher !== 'string') throw new Error(`${label} matcher must be a string`);
validateMatcher(value.matcher, event, label);
}
if (value.sequential !== undefined && typeof value.sequential !== "boolean") {
throw new Error(`${location} sequential must be a boolean`);
if (value.sequential !== undefined && typeof value.sequential !== 'boolean') {
throw new Error(`${label} sequential must be a boolean`);
}
if (!Array.isArray(value.hooks))
throw new Error(`${location} must contain a hooks array`);
const hooks = value.hooks.map((handler, handlerIndex) =>
validateHandler(handler, `${location}.hooks[${handlerIndex}]`),
);
return { ...value, hooks } as QwenGroup;
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
return {
...value,
hooks: value.hooks.map((handler, handlerIndex) => (
validateHandler(handler, `${label}.hooks[${handlerIndex}]`)
)),
} as QwenGroup;
}
export function readHooks(value: unknown, label: string): QwenHookSettings {
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
const settings: QwenHookSettings = { ...value };
for (const [event, definitions] of Object.entries(value)) {
if (CONFIG_FIELDS.has(event)) continue;
if (!EVENT_NAMES.has(event))
throw new Error(`${label} contains unsupported field "${event}"`);
if (!Array.isArray(definitions))
throw new Error(`${label} field "${event}" must be an array`);
settings[event] = definitions.map((definition, index) =>
validateGroup(definition, `${label} field "${event}"`, index),
);
}
return settings;
}
function readQuotedArgument(
command: string,
start: number,
): ParsedArgument | undefined {
if (command[start] !== "'") return undefined;
let value = "";
for (let index = start + 1; index < command.length; ) {
if (process.platform === "win32" && command.startsWith("''", index)) {
value += "'";
index += 2;
export function readQwenHooks(value: unknown): QwenHooks {
if (value === undefined) return {};
if (!isObject(value)) throw new Error('Qwen Code settings field "hooks" must be an object');
const hooks: QwenHooks = {};
for (const [event, groups] of Object.entries(value)) {
if (!KNOWN_EVENTS.has(event)) {
hooks[event] = groups as QwenGroup[];
continue;
}
if (process.platform !== "win32" && command.startsWith(`'"'"'`, index)) {
value += "'";
index += 5;
continue;
if (!Array.isArray(groups)) {
throw new Error(`Qwen Code settings field "hooks.${event}" must be an array`);
}
if (command[index] === "'") return { value, next: index + 1 };
value += command[index];
index += 1;
hooks[event] = groups.map((group, index) => validateGroup(group, event, index));
}
return undefined;
return hooks;
}
function parseGeneratedCommand(
command: string,
): readonly [string, string] | undefined {
if (process.platform === "win32" && !command.startsWith("& "))
return undefined;
const start = process.platform === "win32" ? 2 : 0;
const executable = readQuotedArgument(command, start);
if (!executable || command[executable.next] !== " ") return undefined;
const script = readQuotedArgument(command, executable.next + 1);
const expectedSuffix =
process.platform === "win32" ? "; exit $LASTEXITCODE" : "";
if (
!script ||
command.slice(script.next) !== expectedSuffix ||
!executable.value ||
!script.value
)
return undefined;
return [executable.value, script.value];
export function buildQwenGroup(scriptPath: string, name: string): QwenGroup {
return {
hooks: [{
type: 'command',
name,
command: buildQwenCommand(scriptPath),
shell: process.platform === 'win32' ? 'powershell' : 'bash',
timeout: HOOK_TIMEOUT_MS,
}],
};
}
function samePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left);
const normalizedRight = path.resolve(right);
return process.platform === "win32"
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
: normalizedLeft === normalizedRight;
function exactCurrentGroup(group: QwenGroup): boolean {
return Object.keys(group).length === 1 && Object.hasOwn(group, 'hooks');
}
function sameAgentId(left: string, right: string): boolean {
return process.platform === "win32"
? left.toLowerCase() === right.toLowerCase()
: left === right;
function exactLegacyGroup(group: QwenGroup): boolean {
return Object.keys(group).sort().join('|') === 'hooks|matcher' && group.matcher === '*';
}
export function managedAgentId(
handler: JsonObject,
function currentReference(
handler: QwenHandler,
scriptName: string,
): string | undefined {
if (
Object.keys(handler).sort().join("\0") !== HANDLER_KEYS.join("\0") ||
handler.type !== "command" ||
handler.timeout !== HOOK_TIMEOUT_MS ||
handler.shell !== (process.platform === "win32" ? "powershell" : "bash") ||
typeof handler.command !== "string"
)
return undefined;
const parsed = parseGeneratedCommand(handler.command);
if (!parsed || !samePath(parsed[0], process.execPath)) return undefined;
const scriptPath = parsed[1];
if (path.basename(scriptPath) !== scriptName) return undefined;
const agentDirectory = path.dirname(scriptPath);
if (
!samePath(path.dirname(agentDirectory), path.join(os.homedir(), ".elydora"))
)
return undefined;
const agentId = path.basename(agentDirectory);
return agentId && agentId !== "." && agentId !== ".." ? agentId : undefined;
hookName: string,
): QwenRuntimeReference | undefined {
return Object.keys(handler).sort().join('|') === 'command|name|shell|timeout|type'
&& handler.type === 'command'
&& handler.name === hookName
&& handler.shell === (process.platform === 'win32' ? 'powershell' : 'bash')
&& handler.timeout === HOOK_TIMEOUT_MS
&& typeof handler.command === 'string'
? qwenRuntimeReference(handler.command, scriptName)
: undefined;
}
function exactOwnedGroup(group: QwenGroup, managedIndexes: number[]): boolean {
return (
Object.keys(group).sort().join("\0") === GROUP_KEYS.join("\0") &&
group.matcher === "*" &&
managedIndexes.length > 0 &&
managedIndexes.length === group.hooks.length
);
function legacyReference(
handler: QwenHandler,
scriptName: string,
): QwenRuntimeReference | undefined {
return Object.keys(handler).sort().join('|') === 'command|shell|timeout|type'
&& handler.type === 'command'
&& handler.shell === (process.platform === 'win32' ? 'powershell' : 'bash')
&& handler.timeout === HOOK_TIMEOUT_MS
&& typeof handler.command === 'string'
? qwenRuntimeReference(handler.command, scriptName)
: undefined;
}
export function managedRemovals(
settings: QwenHookSettings,
function managedReference(
handler: QwenHandler,
scriptName: string,
hookName: string,
includeLegacy: boolean,
): QwenRuntimeReference | undefined {
return currentReference(handler, scriptName, hookName)
?? (includeLegacy ? legacyReference(handler, scriptName) : undefined);
}
const EVENT_CONTRACTS = [
['PreToolUse', GUARD_SCRIPT, GUARD_HOOK_NAME],
['PostToolUse', AUDIT_SCRIPT, AUDIT_HOOK_NAME],
['PostToolUseFailure', AUDIT_SCRIPT, AUDIT_HOOK_NAME],
] as const;
export function managedQwenRemovals(
hooks: QwenHooks,
agentId?: string,
): ManagedRemoval[] {
const removals: ManagedRemoval[] = [];
for (const [event, scriptName] of [
["PreToolUse", GUARD_SCRIPT],
["PostToolUse", AUDIT_SCRIPT],
] as const) {
(settings[event] ?? []).forEach((group, groupIndex) => {
for (const [event, scriptName, hookName] of EVENT_CONTRACTS) {
(hooks[event] ?? []).forEach((group, groupIndex) => {
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
const managedId = managedAgentId(handler, scriptName);
return managedId &&
(agentId === undefined || sameAgentId(managedId, agentId))
const reference = managedReference(handler, scriptName, hookName, true);
return reference && (agentId === undefined || sameQwenAgentId(reference.agentId, agentId))
? [handlerIndex]
: [];
});
@@ -355,7 +292,8 @@ export function managedRemovals(
event,
groupIndex,
handlerIndexes,
removeGroup: exactOwnedGroup(group, handlerIndexes),
removeGroup: (exactCurrentGroup(group) || exactLegacyGroup(group))
&& handlerIndexes.length === group.hooks.length,
});
}
});
@@ -363,30 +301,47 @@ export function managedRemovals(
return removals;
}
function managedIds(groups: QwenGroup[], scriptName: string): Set<string> {
const ids = new Set<string>();
function referencesForEvent(
groups: readonly QwenGroup[],
scriptName: string,
hookName: string,
): Map<string, QwenRuntimeReference[]> {
const references = new Map<string, QwenRuntimeReference[]>();
for (const group of groups) {
if (!exactCurrentGroup(group)) continue;
for (const handler of group.hooks) {
const agentId = managedAgentId(handler, scriptName);
if (agentId) ids.add(agentId);
const reference = currentReference(handler, scriptName, hookName);
if (!reference) continue;
const key = process.platform === 'win32'
? reference.agentId.toLowerCase()
: reference.agentId;
references.set(key, [...(references.get(key) ?? []), reference]);
}
}
return ids;
return references;
}
export function runtimeContracts(
settings: QwenHookSettings,
): RuntimeContract[] {
const guards = managedIds(settings.PreToolUse ?? [], GUARD_SCRIPT);
const audits = managedIds(settings.PostToolUse ?? [], AUDIT_SCRIPT);
const root = path.join(os.homedir(), ".elydora");
return [...guards]
.filter((agentId) =>
[...audits].some((auditId) => sameAgentId(auditId, agentId)),
)
.map((agentId) => ({
agentId,
guardPath: path.join(root, agentId, GUARD_SCRIPT),
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
}));
export function qwenRuntimeContracts(hooks: QwenHooks): QwenRuntimeContract[] {
const guards = referencesForEvent(hooks.PreToolUse ?? [], GUARD_SCRIPT, GUARD_HOOK_NAME);
const posts = referencesForEvent(hooks.PostToolUse ?? [], AUDIT_SCRIPT, AUDIT_HOOK_NAME);
const failures = referencesForEvent(
hooks.PostToolUseFailure ?? [],
AUDIT_SCRIPT,
AUDIT_HOOK_NAME,
);
const contracts: QwenRuntimeContract[] = [];
for (const [key, guard] of guards) {
const post = posts.get(key);
const failure = failures.get(key);
if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue;
if (!sameQwenPath(post[0].scriptPath, failure[0].scriptPath)) continue;
contracts.push({
agentId: guard[0].agentId,
guardPath: guard[0].scriptPath,
auditPath: post[0].scriptPath,
});
}
return contracts;
}
export { isObject, type JsonObject };