fix(droid): align hooks with Factory 0.175

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 13:42:26 -04:00
1 parent 2f988e72ba
commit 590de9dbdb
12 files changed
+1855 -932

No files matched your search

+114 -96
View File
@@ -1,5 +1,6 @@
import os from 'node:os';
import path from 'node:path';
import type { JsonObject } from './strict-json.js';
export const AGENT_KEY = 'droid';
export const GUARD_SCRIPT = 'guard.js';
@@ -7,22 +8,10 @@ export const AUDIT_SCRIPT = 'hook.js';
export const HOOK_TIMEOUT_SECONDS = 10;
export const TOOL_EVENTS = ['PreToolUse', 'PostToolUse'] as const;
const EVENT_NAMES = new Set([
'PreToolUse',
'PostToolUse',
'Notification',
'UserPromptSubmit',
'Stop',
'SubagentStop',
'PreCompact',
'SessionStart',
'SessionEnd',
]);
const FLAG_NAMES = new Set(['hooksDisabled', 'showHookOutput']);
const HANDLER_KEYS = ['command', 'timeout', 'type'];
const GROUP_KEYS = ['hooks', 'matcher'];
const WINDOWS_EXIT_SUFFIX = '; exit $LASTEXITCODE';
export type JsonObject = Record<string, unknown>;
export type ToolEvent = typeof TOOL_EVENTS[number];
export interface DroidHandler extends JsonObject {
@@ -37,11 +26,9 @@ export interface DroidGroup extends JsonObject {
readonly hooks: DroidHandler[];
}
export interface DroidHookSettings extends JsonObject {
export interface DroidHookMap extends JsonObject {
readonly PreToolUse?: DroidGroup[];
readonly PostToolUse?: DroidGroup[];
readonly hooksDisabled?: boolean;
readonly showHookOutput?: boolean;
}
export interface RuntimeContract {
@@ -51,7 +38,7 @@ export interface RuntimeContract {
}
export interface ManagedRemoval {
readonly event: string;
readonly event: ToolEvent;
readonly groupIndex: number;
readonly handlerIndexes: number[];
readonly removeGroup: boolean;
@@ -62,25 +49,21 @@ interface ParsedArgument {
readonly next: number;
}
export function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function hasOwn(value: JsonObject, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quoteWindows(value: string): string {
return `"${value.replaceAll('"', '\\"')}"`;
function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
export function buildCommand(scriptPath: string): string {
const quote = process.platform === 'win32' ? quoteWindows : quotePosix;
return `${quote(process.execPath)} ${quote(scriptPath)}`;
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
throw new Error('Factory Droid hook commands require absolute executable and script paths');
}
return process.platform === 'win32'
? `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}${WINDOWS_EXIT_SUFFIX}`
: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
}
export function buildGroup(scriptPath: string): DroidGroup {
@@ -111,65 +94,76 @@ function validateHandler(
handlerIndex: number,
): DroidHandler {
const location = `${label}[${groupIndex}].hooks[${handlerIndex}]`;
if (!isObject(value)) throw new Error(`${location} must be an object`);
if (value.type !== 'command') throw new Error(`${location} type must be "command"`);
if (typeof value.command !== 'string') throw new Error(`${location} command must be a string`);
if (value.timeout !== undefined
&& (typeof value.timeout !== 'number'
|| !Number.isFinite(value.timeout))) {
throw new Error(`${location} timeout must be a finite number`);
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${location} must be an object`);
}
return value as DroidHandler;
const handler = value as JsonObject;
if (handler.type !== 'command') throw new Error(`${location} type must be "command"`);
if (typeof handler.command !== 'string' || handler.command.trim().length === 0) {
throw new Error(`${location} command must be a non-empty string`);
}
if (handler.timeout !== undefined
&& (typeof handler.timeout !== 'number'
|| !Number.isFinite(handler.timeout)
|| handler.timeout <= 0)) {
throw new Error(`${location} timeout must be a positive finite number`);
}
return handler as DroidHandler;
}
function validateGroup(value: unknown, label: string, groupIndex: number): DroidGroup {
const location = `${label}[${groupIndex}]`;
if (!isObject(value)) throw new Error(`${location} 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`, true);
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${location} must be an object`);
}
if (value.commandRegex !== undefined) {
if (typeof value.commandRegex !== 'string') {
const group = value as JsonObject;
if (group.matcher !== undefined) {
if (typeof group.matcher !== 'string') throw new Error(`${location} matcher must be a string`);
validateRegex(group.matcher, `${location} matcher`, true);
}
if (group.commandRegex !== undefined) {
if (typeof group.commandRegex !== 'string') {
throw new Error(`${location} commandRegex must be a string`);
}
validateRegex(value.commandRegex, `${location} commandRegex`, false);
validateRegex(group.commandRegex, `${location} commandRegex`, false);
}
if (!Array.isArray(value.hooks)) throw new Error(`${location} must contain a hooks array`);
const handlers = value.hooks.map(
(handler, handlerIndex) => validateHandler(handler, label, groupIndex, handlerIndex),
);
return { ...value, hooks: handlers } as DroidGroup;
if (!Array.isArray(group.hooks)) throw new Error(`${location} must contain a hooks array`);
return {
...group,
hooks: group.hooks.map(
(handler, handlerIndex) => validateHandler(handler, label, groupIndex, handlerIndex),
),
} as DroidGroup;
}
export function readHookSettings(value: unknown, label: string): DroidHookSettings {
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
const settings: DroidHookSettings = { ...value };
for (const [key, item] of Object.entries(value)) {
if (FLAG_NAMES.has(key)) {
if (typeof item !== 'boolean') throw new Error(`${label} field "${key}" must be a boolean`);
continue;
}
if (!EVENT_NAMES.has(key)) throw new Error(`${label} contains unsupported field "${key}"`);
if (!Array.isArray(item)) throw new Error(`${label} field "${key}" must be an array`);
settings[key] = item.map(
(group, groupIndex) => validateGroup(group, `${label} field "${key}"`, groupIndex),
export function readHookMap(value: unknown, label: string): DroidHookMap {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${label} must contain a JSON object`);
}
const hooks: DroidHookMap = {};
for (const [event, groups] of Object.entries(value)) {
if (!Array.isArray(groups)) throw new Error(`${label} field "${event}" must be an array`);
hooks[event] = groups.map(
(group, groupIndex) => validateGroup(group, `${label} field "${event}"`, groupIndex),
);
}
return settings;
return hooks;
}
function readWindowsArgument(command: string, start: number): ParsedArgument | undefined {
if (command[start] !== '"') return undefined;
function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined {
if (command[start] !== "'") return undefined;
let value = '';
for (let index = start + 1; index < command.length; index += 1) {
if (command[index] === '\\' && command[index + 1] === '"') {
value += '"';
if (command[index] !== "'") {
value += command[index];
continue;
}
if (command[index + 1] === "'") {
value += "'";
index += 1;
continue;
}
if (command[index] === '"') return { value, next: index + 1 };
value += command[index];
return { value, next: index + 1 };
}
return undefined;
}
@@ -192,16 +186,45 @@ function readPosixArgument(command: string, start: number): ParsedArgument | und
return undefined;
}
function parseGeneratedCommand(command: string): readonly [string, string] | undefined {
const readArgument = process.platform === 'win32' ? readWindowsArgument : readPosixArgument;
const executable = readArgument(command, 0);
function parseTwoArguments(
command: string,
start: number,
readArgument: (command: string, start: number) => ParsedArgument | undefined,
): readonly [string, string] | undefined {
const executable = readArgument(command, start);
if (!executable || command[executable.next] !== ' ') return undefined;
const script = readArgument(command, executable.next + 1);
if (!script || script.next !== command.length || !executable.value || !script.value) return undefined;
return [executable.value, script.value];
}
function samePath(left: string, right: string): boolean {
function parseLegacyWindowsCommand(command: string): readonly [string, string] | undefined {
const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command);
return match ? [match[1], match[2]] : undefined;
}
function parsePowerShellCommand(command: string): readonly [string, string] | undefined {
if (!command.startsWith('& ')) return undefined;
const executable = readPowerShellArgument(command, 2);
if (!executable || command[executable.next] !== ' ') return undefined;
const script = readPowerShellArgument(command, executable.next + 1);
if (!script
|| command.slice(script.next) !== WINDOWS_EXIT_SUFFIX
|| !executable.value
|| !script.value) return undefined;
return [executable.value, script.value];
}
function parseGeneratedCommand(
command: string,
includeLegacy: boolean,
): readonly [string, string] | undefined {
if (process.platform !== 'win32') return parseTwoArguments(command, 0, readPosixArgument);
const current = parsePowerShellCommand(command);
return current ?? (includeLegacy ? parseLegacyWindowsCommand(command) : undefined);
}
export function samePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left);
const normalizedRight = path.resolve(right);
return process.platform === 'win32'
@@ -209,15 +232,19 @@ function samePath(left: string, right: string): boolean {
: normalizedLeft === normalizedRight;
}
function sameAgentId(left: string, right: string): boolean {
export function sameAgentId(left: string, right: string): boolean {
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
}
export function managedAgentId(handler: DroidHandler, scriptName: string): string | undefined {
export function managedAgentId(
handler: DroidHandler,
scriptName: string,
includeLegacy = false,
): string | undefined {
if (Object.keys(handler).sort().join('\0') !== HANDLER_KEYS.join('\0')
|| handler.type !== 'command'
|| handler.timeout !== HOOK_TIMEOUT_SECONDS) return undefined;
const parsed = parseGeneratedCommand(handler.command);
const parsed = parseGeneratedCommand(handler.command, includeLegacy);
if (!parsed || !samePath(parsed[0], process.execPath)) return undefined;
const scriptPath = parsed[1];
if (path.basename(scriptPath) !== scriptName) return undefined;
@@ -234,19 +261,16 @@ function exactOwnedGroup(group: DroidGroup, managedIndexes: number[]): boolean {
&& managedIndexes.length === group.hooks.length;
}
export function managedRemovals(
settings: DroidHookSettings,
agentId?: string,
): ManagedRemoval[] {
export function managedRemovals(hooks: DroidHookMap, agentId?: string): ManagedRemoval[] {
const removals: ManagedRemoval[] = [];
for (const [event, scriptName] of [
['PreToolUse', GUARD_SCRIPT],
['PostToolUse', AUDIT_SCRIPT],
] as const) {
const groups = settings[event] ?? [];
const groups = hooks[event] ?? [];
groups.forEach((group, groupIndex) => {
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
const managedId = managedAgentId(handler, scriptName);
const managedId = managedAgentId(handler, scriptName, true);
return managedId && (agentId === undefined || sameAgentId(managedId, agentId))
? [handlerIndex]
: [];
@@ -264,20 +288,21 @@ export function managedRemovals(
return removals;
}
function managedIds(groups: DroidGroup[], scriptName: string): Set<string> {
function configuredIds(groups: DroidGroup[], scriptName: string): Set<string> {
const ids = new Set<string>();
for (const group of groups) {
for (const handler of group.hooks) {
const agentId = managedAgentId(handler, scriptName);
if (agentId) ids.add(agentId);
}
if (Object.keys(group).sort().join('\0') !== GROUP_KEYS.join('\0')
|| group.matcher !== '*'
|| group.hooks.length !== 1) continue;
const agentId = managedAgentId(group.hooks[0], scriptName);
if (agentId) ids.add(agentId);
}
return ids;
}
export function runtimeContracts(settings: DroidHookSettings): RuntimeContract[] {
const guards = managedIds(settings.PreToolUse ?? [], GUARD_SCRIPT);
const audits = managedIds(settings.PostToolUse ?? [], AUDIT_SCRIPT);
export function runtimeContracts(hooks: DroidHookMap): RuntimeContract[] {
const guards = configuredIds(hooks.PreToolUse ?? [], GUARD_SCRIPT);
const audits = configuredIds(hooks.PostToolUse ?? [], AUDIT_SCRIPT);
const root = path.join(os.homedir(), '.elydora');
return [...guards]
.filter((agentId) => [...audits].some((auditId) => sameAgentId(auditId, agentId)))
@@ -287,10 +312,3 @@ export function runtimeContracts(settings: DroidHookSettings): RuntimeContract[]
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
}));
}
export function mergeHookSettings(
primary: DroidHookSettings | undefined,
fallback: DroidHookSettings | undefined,
): DroidHookSettings {
return { ...(fallback ?? {}), ...(primary ?? {}) };
}