UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

254 lines 10.6 kB
import { pushUniquePath } from "./routing.js"; /** Normalize hook command arguments into a repo-relative shell-script path. */ function normalizeHookPath(candidate) { if (!candidate) return null; let path = candidate.trim(); if (!path) return null; path = path.replace(/^['"`]|['"`]$/g, ""); // Hook launchers prefix the script path with a resolved repo root, either as // an inline substitution ($(git rev-parse --show-toplevel)/...sh) or a shell // variable ($root/...sh, $REPO/...sh) populated earlier in the command. Strip // either prefix so callers see a repo-relative script path. const substitutionMatch = path.match(/\$(?:\([^)]*\)|\{?\w+\}?)\/(.*\.sh)$/); if (substitutionMatch && substitutionMatch[1]) { path = substitutionMatch[1]; } if (!path.endsWith(".sh")) return null; return path; } /** Extract normalized shell-script paths from one hook command string. */ function extractHookPathsFromCommand(command) { const pathCandidates = []; const quotedMatches = command.matchAll(/["']([^"']+\.sh)["']/g); for (const match of quotedMatches) { const path = match[1]; if (path === undefined) continue; const normalized = normalizeHookPath(path); if (normalized) pushUniquePath(pathCandidates, normalized); } const unquotedMatches = command.matchAll(/([^\s"'`]+\.sh)/g); for (const match of unquotedMatches) { const path = match[1]; if (path === undefined) continue; const normalized = normalizeHookPath(path); if (normalized) pushUniquePath(pathCandidates, normalized); } return pathCandidates; } /** Return the preferred shell-script path referenced by a list of hook commands. */ function preferredHookPathFromCommands(commands) { const paths = []; for (const command of commands) { const candidates = extractHookPathsFromCommand(command); for (const candidate of candidates) pushUniquePath(paths, candidate); } const preferred = paths.find((path) => path.endsWith("/post-turn-safety.sh")) ?? paths.find((path) => path.endsWith("/deny-dangerous.sh")) ?? paths.find((path) => path.endsWith("/guard-repository-writes.sh")) ?? paths.find((path) => !path.endsWith("/plan-checkbox-guard.sh")) ?? null; return preferred; } /** Return the parsed `hooks` object from settings when it exists. */ function readHooksObject(settingsParsed) { if (!settingsParsed || typeof settingsParsed !== "object") return null; const hooks = settingsParsed.hooks; if (!hooks || typeof hooks !== "object") return null; return hooks; } /** Return one Antigravity top-level hook definition by goat-flow hook id. */ function readAntigravityHookDefinition(hookConfigParsed, hookId) { if (!hookConfigParsed || typeof hookConfigParsed !== "object") return null; const definition = hookConfigParsed[hookId]; if (!definition || typeof definition !== "object") return null; return definition; } /** Return the first Antigravity top-level hook definition registered for the post-turn event. */ function readAntigravityPostTurnRegistration(agent, hookConfigParsed) { if (agent.id !== "antigravity" || !agent.hookEvents?.postTurn) return null; if (!hookConfigParsed || typeof hookConfigParsed !== "object") return null; const commands = []; for (const definition of Object.values(hookConfigParsed)) { if (!definition || typeof definition !== "object") continue; const hookDefinition = definition; if (hookDefinition.enabled === false) continue; commands.push(...extractCommandsFromEventConfig(hookDefinition, agent.hookEvents.postTurn)); } const path = preferredHookPathFromCommands(commands); return { isRegistered: path !== null, path }; } /** Extract the shell command from one hook entry when it uses command mode. */ function hasSupportedHookType(hookObj) { return (hookObj.type === undefined || hookObj.type === "command" || hookObj.type === "Command"); } /** Read one shell command field from a normalized hook object. */ function readHookCommand(hookObj) { if (typeof hookObj.bash === "string") return hookObj.bash; if (typeof hookObj.command === "string") return hookObj.command; const nestedCommand = hookObj.command; if (!nestedCommand || typeof nestedCommand !== "object") return null; return typeof nestedCommand.bash === "string" ? nestedCommand.bash : null; } /** Extract the shell command from supported hook payload shapes. */ function extractCommandFromHook(hook) { if (!hook || typeof hook !== "object") return null; const hookObj = hook; if (!hasSupportedHookType(hookObj)) return null; return readHookCommand(hookObj); } /** Extract all shell commands declared inside one event registration entry. */ function extractCommandsFromEventEntry(entry) { if (!entry || typeof entry !== "object") return []; const directCommand = extractCommandFromHook(entry); if (directCommand !== null) return [directCommand]; const eventHooks = entry.hooks; if (!Array.isArray(eventHooks)) return []; const commands = []; for (const hook of eventHooks) { const command = extractCommandFromHook(hook); if (command !== null) commands.push(command); } return commands; } /** Extract all shell commands from one normalized event config. */ function extractCommandsFromEventConfig(hooks, event) { if (!event) return []; const rawEvent = hooks[event]; if (!Array.isArray(rawEvent)) return []; const commands = []; for (const entry of rawEvent) { commands.push(...extractCommandsFromEventEntry(entry)); } return commands; } /** Normalize one event's hook registration into a simple registered/path pair. */ function normalizeEventConfig(hooks, event) { const commands = extractCommandsFromEventConfig(hooks, event); const path = preferredHookPathFromCommands(commands); return { isRegistered: path !== null, path }; } /** * Resolve the parsed hook config for one agent, reusing the already-parsed * settings file when the agent stores hooks there and only reading a separate * file otherwise - this avoids parsing the same file twice. * * @param fs - read-only filesystem adapter used only when hooks live in a separate file * @param agent - agent profile naming its settings and hook-config files * @param settingsParsed - already-parsed settings content, reused when it doubles as the hook config * @param settingsValid - whether that pre-parsed settings content parsed successfully * @returns the parsed hook config and its validity; both default to null/false when the agent declares no hook file */ export function readHookConfig(fs, agent, settingsParsed, settingsValid) { if (!agent.hookConfigFile) { return { parsed: null, valid: false }; } if (agent.hookConfigFile === agent.settingsFile) { return { parsed: settingsParsed, valid: settingsValid }; } const parsed = fs.readJson(agent.hookConfigFile); return { parsed, valid: parsed !== null }; } /** * Report whether the agent's post-turn (learning-loop) hook is registered and * which script it points at. Returns the not-registered shape for agents that * declare no hook events or whose config has no usable `hooks` object. * * @param agent - agent profile naming its post-turn hook event, if any * @param hookConfigParsed - parsed hook config from readHookConfig, or null/invalid content * @returns post-turn registration flag and resolved script path; path is null when not registered */ export function buildHookRegistration(agent, hookConfigParsed) { const antigravityPostTurn = readAntigravityPostTurnRegistration(agent, hookConfigParsed); if (antigravityPostTurn !== null) { return { postTurnRegistered: antigravityPostTurn.isRegistered, postTurnRegisteredPath: antigravityPostTurn.path, }; } const hooks = readHooksObject(hookConfigParsed); if (!hooks) { return { postTurnRegistered: false, postTurnRegisteredPath: null, }; } if (!agent.hookEvents) { return { postTurnRegistered: false, postTurnRegisteredPath: null }; } const postTurn = normalizeEventConfig(hooks, agent.hookEvents.postTurn); return { postTurnRegistered: postTurn.isRegistered, postTurnRegisteredPath: postTurn.path, }; } /** * Report whether the dangerous-command deny guard is registered as a pre-tool * hook, and its script path. Antigravity is handled separately because its deny * hook is a top-level keyed definition with its own `enabled` flag, so an * explicit `enabled: false` there counts as not registered. * * @param agent - agent profile naming its pre-tool hook event and identifying Antigravity * @param hookConfigParsed - parsed hook config from readHookConfig, or null/invalid content * @returns deny registration flag and resolved script path; path is null when not registered or disabled */ export function buildDenyRegistration(agent, hookConfigParsed) { if (agent.id === "antigravity") { if (!agent.hookEvents) { return { denyIsRegistered: false, denyRegisteredPath: null }; } const denyDefinition = readAntigravityHookDefinition(hookConfigParsed, "deny-dangerous") ?? readAntigravityHookDefinition(hookConfigParsed, "guard-repository-writes"); if (!denyDefinition || denyDefinition.enabled === false) { return { denyIsRegistered: false, denyRegisteredPath: null }; } const preTool = normalizeEventConfig(denyDefinition, agent.hookEvents.preTool); return { denyIsRegistered: preTool.isRegistered, denyRegisteredPath: preTool.path, }; } const hooks = readHooksObject(hookConfigParsed); if (!hooks) { return { denyIsRegistered: false, denyRegisteredPath: null }; } if (!agent.hookEvents) { return { denyIsRegistered: false, denyRegisteredPath: null }; } const preTool = normalizeEventConfig(hooks, agent.hookEvents.preTool); return { denyIsRegistered: preTool.isRegistered, denyRegisteredPath: preTool.path, }; } //# sourceMappingURL=hook-registration.js.map