import { appendEvent } from "../state/event-log/event-log.ts"; import type { TeamRunManifest } from "../state/types.ts"; import { runEventBus } from "../ui/run-event-bus.ts"; import type { HookContext, HookDefinition, HookExecutionReport, HookName, HookResult } from "./types.ts"; const registry = new Map(); // Track hook IDs registered by pi-crew for scope-aware cleanup const _piCrewHookIds = new Set(); let _nextHookId = 1; // SECURITY: Hooks are currently global (registered once, applied to all workspaces). // For multi-workspace environments, consider filtering hooks by workspace scope: // const workspaceHooks = getHooks(name).filter(h => !h.workspaceId || h.workspaceId === ctx.workspaceId); // This prevents globally-registered hooks from operating on runs they weren't designed for. export function registerHook(definition: HookDefinition): number { const hookId = _nextHookId++; _piCrewHookIds.add(hookId); const hooks = registry.get(definition.name) ?? []; hooks.push({ ...definition, _hookId: hookId }); registry.set(definition.name, hooks); return hookId; } // Scope-aware hook clearing: only removes hooks registered by pi-crew // (keeps hooks registered by other extensions/hosts intact). export function clearHooksScoped(): void { for (const [name, hooks] of registry) { const remaining = hooks.filter((h) => !("_hookId" in h && _piCrewHookIds.has((h as { _hookId?: number })._hookId ?? -1))); if (remaining.length === 0) { registry.delete(name); } else { registry.set(name, remaining); } } _piCrewHookIds.clear(); _nextHookId = 1; } export function getHooks(name: HookName): HookDefinition[] { return registry.get(name) ?? []; } // PERF (2026-08-24): constant sanitizer state hoisted to module scope — it was // rebuilt (12 normalize+toLowerCase + Set + 3 closures) on EVERY hook execution. const POLLUTED_KEYS = new Set( [ "__proto__", "constructor", "prototype", "hasOwnProperty", "toString", "valueOf", "isPrototypeOf", "propertyIsEnumerable", "__defineGetter__", "__defineSetter__", "__lookupGetter__", "__lookupSetter__", ].map((k) => k.toLowerCase().normalize("NFKC")), ); function sanitizeMergeData(data: Record): Record { const clean: Record = {}; for (const [k, v] of Object.entries(data)) { if (!POLLUTED_KEYS.has(k.toLowerCase().normalize("NFKC"))) { if (v !== null && typeof v === "object") { if (Array.isArray(v)) { // Sanitize array elements that are objects clean[k] = v.map((item) => item !== null && typeof item === "object" && !Array.isArray(item) ? sanitizeMergeData(item as Record) : item, ); } else { clean[k] = sanitizeMergeData(v as Record); } } else { clean[k] = v; } } } return clean; } // Sanitize ctx by stripping dangerous property names before passing to handlers. // Hook authors must NOT set these keys directly on ctx: [...POLLUTED_KEYS] // This sanitization runs at the start of executeHook to prevent prototype pollution attacks. function sanitizeContext(ctx: HookContext): HookContext { for (const key of Object.keys(ctx)) { if (POLLUTED_KEYS.has(key.toLowerCase().normalize("NFKC"))) { delete ctx[key]; } } return ctx; } function sanitizeErrorMessage(message: string): string { // Remove file paths, environment variable references, and other potentially sensitive data return message .replace(/\/[^:\s]+/g, "[path]") .replace(/\b[A-Z_0-9]+\s*=/g, "[env]") .replace(/\b\d+\.\d+\.\d+\.\d+\b/g, "[ip]"); } export async function executeHook(name: HookName, ctx: HookContext): Promise { const hooks = getHooks(name); if (hooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 }; // SECURITY: Filter hooks by workspace scope. // - Global hooks (no workspaceId) match all contexts UNLESS ctx explicitly // opts out via `includeGlobalHooks: false`. This is the documented // behavior: "Hooks without workspaceId match ALL workspaces". // - Scoped hooks (workspaceId set) require an exact match with ctx.workspaceId. // - If ctx has no workspaceId, scoped hooks are excluded (they can't match). // - `includeGlobalHooks: true` on ctx forces inclusion of global hooks even // when ctx has a workspaceId. const scopedHooks = hooks.filter((h) => { // Scoped hook: must match ctx.workspaceId exactly if (h.workspaceId !== undefined) { return h.workspaceId === ctx.workspaceId; } // Global hook (no workspaceId): include unless ctx explicitly excludes return ctx.includeGlobalHooks !== false; }); if (scopedHooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 }; const start = Date.now(); const diagnostics: string[] = []; let capturedModifications: Record | undefined; for (const hook of scopedHooks) { try { const result: HookResult = await hook.handler(sanitizeContext(ctx)); // SECURITY: Sanitize any direct mutations the handler may have made to ctx. // This prevents hooks from injecting dangerous properties via direct ctx assignment. sanitizeContext(ctx); if (hook.mode === "blocking" && result.outcome === "block") { return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: result.reason, }; } if (result.outcome === "modify" && result.data) { // EXT-13 (Round 3): Deep-clone hook result.data so subsequent hooks // (or the same hook if reused) cannot mutate shared ctx via shared // nested-object references. sanitizeMergeData already produces a // fresh top-level object, but nested values would still alias. const clonedData = sanitizeMergeData(result.data); Object.assign(ctx, clonedData); capturedModifications = clonedData; } } catch (error) { const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error)); if (hook.mode === "blocking") { return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: `Hook error: ${message}`, }; } // Non-blocking hook errors are accumulated as diagnostics; continue to next hook diagnostics.push(message); } } if (diagnostics.length > 0) { return { hookName: name, outcome: "diagnostic", durationMs: Date.now() - start, reason: diagnostics.join("; "), modifiedData: capturedModifications, }; } return { hookName: name, outcome: "allow", durationMs: Date.now() - start, modifiedData: capturedModifications, }; } export function appendHookEvent(manifest: TeamRunManifest, report: HookExecutionReport): void { // REVIEW FIX (2026-09-10): reverted M2b buffered conversion — hook.executed // events are read back synchronously (recovery-hooks tests, hooks audit // display) and are low-frequency (per hook execution). appendEvent(manifest.eventsPath, { type: "hook.executed", runId: manifest.runId, message: `Hook ${report.hookName} completed with outcome=${report.outcome}${report.reason ? `: ${report.reason}` : ""}`, data: { hookName: report.hookName, outcome: report.outcome, durationMs: report.durationMs, reason: report.reason, }, }); runEventBus.emit({ type: "effectiveness_changed", runId: manifest.runId, data: { hookName: report.hookName, outcome: report.outcome }, }); }