/** * @file Matches hook events against pipeline-defined stage-detection rules. * Inputs and templates are untrusted, so each exported function is total and * compiled regular expressions are cached once for each pipeline object. * @author Nguyễn Ngọc Trí Vĩ */ const compiledPipelines = new WeakMap(); // Only fields that identify WHAT a tool did, never editor payload like // old_string/new_string, which can be whole code blocks. const FLATTEN_KEYS = new Set([ "command", "file_path", "skill", "prompt", "pattern", "description", "subagent_type", ]); const SIGNAL_MAX = 120; /** Collapse whitespace and cap to a bounded, readable length. */ function capSignal(text) { const collapsed = text.replace(/\s+/g, " ").trim(); return collapsed.length > SIGNAL_MAX ? `${collapsed.slice(0, SIGNAL_MAX)}…` : collapsed; } /** Characters of surrounding context kept on each side of a matched span. */ const SIGNAL_CONTEXT = 24; /** * The span the rule actually matched, plus a little context — the whole * flattened input is usually a long shell line whose interesting part is a few * words in the middle (`cd /very/long/path && npm run test:server 2>&1 | tail`). * A rule with no regex matched on the tool name alone and has no span, so the * caller keeps the flattened input. */ function matchedSpan(regex, input) { if (!regex) return null; // `regex` may carry /g from a user template; lastIndex would make exec() // stateful across calls, so search from a known position every time. regex.lastIndex = 0; const m = regex.exec(input); if (!m || typeof m.index !== "number") return null; const start = Math.max(0, m.index - SIGNAL_CONTEXT); const end = Math.min(input.length, m.index + m[0].length + SIGNAL_CONTEXT); const prefix = start > 0 ? "…" : ""; const suffix = end < input.length ? "…" : ""; return `${prefix}${input.slice(start, end)}${suffix}`; } /** * Known identifying string fields of a tool input, kept SEPARATE, without * recursively walking it. * * Rules are tested one field at a time (see detect) rather than against a join * of them. An `Agent` input really carries `{description, prompt, * subagent_type}`, so on a joined string a rule anchored to the agent's * identity — `^Explore$`, `^Plan$` in ship-feature.json — can never match: the * anchors sit against the description and the prompt instead. Per field they * match the thing they were written for, and no rule can match across the seam * between two unrelated fields. */ function inputFields(toolInput) { try { if (typeof toolInput === "string") return [toolInput]; if (toolInput === null || typeof toolInput !== "object") return []; const strings = []; if (Array.isArray(toolInput)) { for (const value of toolInput) { if (typeof value === "string") strings.push(value); else if (value && typeof value === "object") { for (const [key, nestedValue] of Object.entries(value)) { if (FLATTEN_KEYS.has(key) && typeof nestedValue === "string") strings.push(nestedValue); } } } } else { for (const [key, value] of Object.entries(toolInput)) { if (FLATTEN_KEYS.has(key) && typeof value === "string") strings.push(value); } } return strings; } catch { return []; } } /** The same fields as one string — the human-readable signal, and the shape * the ingest paths log. Never what a rule is matched against. */ function flattenInput(toolInput) { return inputFields(toolInput).join(" "); } /** Compile valid node rules once, omitting malformed nodes and regex patterns. */ function compileRules(pipeline) { if (!pipeline || typeof pipeline !== "object") return []; const cached = compiledPipelines.get(pipeline); if (cached) return cached; let nodes = []; try { nodes = Array.isArray(pipeline.nodes) ? pipeline.nodes : []; } catch { return []; } const compiled = nodes.map((node) => { const rules = []; try { const detectRules = Array.isArray(node && node.detect) ? node.detect : []; for (const rule of detectRules) { if (!rule || typeof rule !== "object" || typeof rule.tool !== "string") continue; if (rule.match === undefined) { rules.push({ tool: rule.tool, regex: null }); continue; } if (typeof rule.match !== "string") continue; try { rules.push({ tool: rule.tool, regex: new RegExp(rule.match) }); } catch { // A user-supplied invalid regex must never block hook ingestion. } } } catch { // Ignore a malformed node while preserving the remaining template. } return { nodeId: node && node.id, rules }; }); compiledPipelines.set(pipeline, compiled); return compiled; } /** Infer the last matching pipeline stage from one hook event, or return null. */ function detect(pipeline, event) { try { if (!event || typeof event.tool_name !== "string" || !event.tool_name) return null; const fields = inputFields(event.tool_input); const fallback = fields.join(" ") || event.tool_name; let match = null; for (const node of compileRules(pipeline)) { if (typeof node.nodeId !== "string" || !node.nodeId) continue; for (const rule of node.rules) { if (rule.tool !== event.tool_name) continue; // A rule with no regex matched on the tool name alone. const hit = rule.regex ? fields.find((f) => { rule.regex.lastIndex = 0; // a /g rule from a user template is stateful return rule.regex.test(f); }) : ""; if (hit === undefined) continue; const span = hit ? matchedSpan(rule.regex, hit) : null; match = { nodeId: node.nodeId, signal: `\`${capSignal(span || fallback)}\`` }; break; } } return match; } catch { return null; } } module.exports = { flattenInput, inputFields, compileRules, detect };