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.

414 lines 17 kB
/** * Registrar that reconciles `.goat-flow/config.yaml` hook truth to detected * hook-capable agent surfaces in the selected project. */ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, } from "node:fs"; import { isAbsolute, join, relative, resolve } from "node:path"; import { getAgentProfiles } from "../agents/registry.js"; import { readHookEnabled, removeHookConfig, removeTopLevelConfigBlock, setHookEnabled, } from "../config/writer.js"; import { getTemplatePath } from "../paths.js"; import { getHookSpec, isValidHookIdShape, listHookSpecs, } from "./hooks-registry.js"; import { readAgentHookState, writeAgentHookState, } from "./agent-hook-writer.js"; import { writeFileAtomic } from "./safe-exec.js"; const DENY_DANGEROUS_POLICY_FILES = [ "patterns-shell.sh", "patterns-paths.sh", "patterns-writes.sh", "deny-dangerous-self-test.sh", ]; const LEGACY_AGENT_HOOK_DIRS = [ ".claude/hooks", ".codex/hooks", ".agents/hooks", ".github/hooks", ]; const LEGACY_DENY_DANGEROUS_SCRIPT_NAMES = [ "guard-common.sh", "guard-destructive-shell.sh", "guard-secret-paths.sh", "guard-repository-writes.sh", "guardrails-self-test.sh", "deny-dangerous.self-test.sh", ]; const REMOVED_HOOK_TOMBSTONES = [ { id: "plan-checkbox-guard", displayName: "Removed plan checkbox guard", description: "Legacy cleanup tombstone for stale plan checkbox guard installs.", event: "Stop", matcher: "", scriptFiles: ["plan-checkbox-guard.sh"], primaryScript: "plan-checkbox-guard.sh", togglable: false, defaultEnabled: false, requiresConfirmDialog: false, }, ]; /** HTTP-safe hook registrar failure with the status code routes should return. */ class HookRegistrarError extends Error { statusCode; constructor(message, statusCode) { super(message); this.statusCode = statusCode; this.name = "HookRegistrarError"; } } export { HookRegistrarError }; /** Validate and resolve a hook id into the registry spec; bad ids throw 400 and unknown ids throw 404. Throws on invalid input. */ function resolveSpec(hookId) { if (!isValidHookIdShape(hookId)) { throw new HookRegistrarError("Invalid hook id", 400); } const spec = getHookSpec(hookId); if (!spec) throw new HookRegistrarError(`Unknown hook: ${hookId}`, 404); return spec; } /** Confirm an agent profile has all manifest paths needed for hook registration. */ function isSupportedAgent(agent) { return (agent.hooksDir !== null && agent.hookConfigFile !== null && agent.hookEvents !== null); } function unsupportedReasonForSpec(spec, agent) { return spec.unsupportedAgents?.[agent.id] ?? null; } /** Block hook script writes that would escape the selected project root; throws a 400 registrar error. */ function assertWithinProject(projectPath, targetPath) { const root = resolve(projectPath); const target = resolve(targetPath); const fromRoot = relative(root, target); if (fromRoot === "" || (fromRoot !== ".." && !fromRoot.startsWith(`..${String.fromCharCode(47)}`) && !fromRoot.startsWith(`..${String.fromCharCode(92)}`) && !isAbsolute(fromRoot))) { return; } throw new HookRegistrarError("Refusing to write outside project path", 400); } function scriptTarget(projectPath, agent, script) { if (!agent.hooksDir) throw new Error(`${agent.id} has no hooks dir`); const target = join(projectPath, agent.hooksDir, script); assertWithinProject(projectPath, target); return target; } function scriptExists(projectPath, agent, spec) { const agentScriptsExist = spec.scriptFiles.every((script) => existsSync(scriptTarget(projectPath, agent, script))); if (!agentScriptsExist) return false; if (spec.id !== "deny-dangerous") return true; return DENY_DANGEROUS_POLICY_FILES.every((file) => existsSync(join(projectPath, ".goat-flow", "hooks", "deny-dangerous", file))); } function profilePathIsUnique(profiles, key, path) { if (!path) return false; return profiles.filter((profile) => profile[key] === path).length === 1; } function agentInstalledSurfaceExists(projectPath, agent, profiles) { const uniqueOptionalMarkers = [ profilePathIsUnique(profiles, "instructionFile", agent.instructionFile) ? agent.instructionFile : null, profilePathIsUnique(profiles, "skillsDir", agent.skillsDir) ? agent.skillsDir : null, ]; const markers = [ agent.settingsFile, agent.hookConfigFile, profilePathIsUnique(profiles, "hooksDir", agent.hooksDir) ? agent.hooksDir : null, ...uniqueOptionalMarkers, ].filter((marker) => typeof marker === "string"); return markers.some((marker) => existsSync(join(projectPath, marker))); } function hookScriptResidueExists(projectPath, agent, spec, profiles) { const scriptFiles = spec.id === "deny-dangerous" ? [...spec.scriptFiles, ...LEGACY_DENY_DANGEROUS_SCRIPT_NAMES] : spec.scriptFiles; if (agent.hooksDir && profilePathIsUnique(profiles, "hooksDir", agent.hooksDir) && scriptFiles.some((script) => existsSync(scriptTarget(projectPath, agent, script)))) { return true; } return LEGACY_AGENT_HOOK_DIRS.some((hooksDir) => scriptFiles.some((script) => existsSync(join(projectPath, hooksDir, script)))); } function shouldReconcileAgent(projectPath, agent, spec, profiles) { return (agentInstalledSurfaceExists(projectPath, agent, profiles) || hookScriptResidueExists(projectPath, agent, spec, profiles)); } /** Check for an existing hook config before writing disabled state for optional hooks. */ function hookConfigExists(projectPath, agent) { return (agent.hookConfigFile !== null && existsSync(join(projectPath, agent.hookConfigFile))); } function ensureGoatFlowGitignoreEntry(projectPath, entry) { const gitignorePath = join(projectPath, ".goat-flow", ".gitignore"); assertWithinProject(projectPath, gitignorePath); mkdirSync(join(projectPath, ".goat-flow"), { recursive: true }); const original = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : ""; const hasFinalNewline = original.length === 0 || original.endsWith("\n"); const lines = original.split(/\r?\n/u).filter((line, index, all) => { return index < all.length - 1 || line.length > 0; }); if (lines.includes(entry)) return; const next = `${lines.join("\n")}${lines.length > 0 ? "\n" : ""}${entry}\n`; writeFileAtomic(gitignorePath, hasFinalNewline ? next : next.trimEnd(), projectPath); } function removeGoatFlowGitignoreEntry(projectPath, entry) { const gitignorePath = join(projectPath, ".goat-flow", ".gitignore"); assertWithinProject(projectPath, gitignorePath); if (!existsSync(gitignorePath)) return; const original = readFileSync(gitignorePath, "utf-8"); const hasFinalNewline = original.endsWith("\n"); const lines = original.split(/\r?\n/u); if (hasFinalNewline) lines.pop(); const nextLines = lines.filter((line) => line !== entry); if (nextLines.length === lines.length) return; const next = `${nextLines.join("\n")}${hasFinalNewline ? "\n" : ""}`; writeFileAtomic(gitignorePath, next, projectPath); } /** * Keep the shared `.goat-flow/hooks/deny-dangerous/` policy store tracked by Git. * * Adds both `!hooks/` and `!hooks/**` negations to `.goat-flow/.gitignore` so the * deny-dangerous policy modules survive a fresh clone; without them a gitignored * `.goat-flow/` drops the store and the guard fails closed on checkout. Idempotent - * each entry is appended only when absent (writes `.goat-flow/.gitignore`). * * @param projectPath - target project root whose `.goat-flow/.gitignore` is updated */ function ensureHookGitignoreEntries(projectPath) { ensureGoatFlowGitignoreEntry(projectPath, "!hooks/"); ensureGoatFlowGitignoreEntry(projectPath, "!hooks/**"); } function removeLegacyAgentScriptIfPresent(projectPath, hooksDir, script) { const target = join(projectPath, hooksDir, script); assertWithinProject(projectPath, target); try { unlinkSync(target); } catch { /* target already gone - stale script pruning is idempotent */ } } function removeLegacyAgentHookScripts(projectPath, spec) { for (const hooksDir of LEGACY_AGENT_HOOK_DIRS) { for (const script of spec.scriptFiles) { removeLegacyAgentScriptIfPresent(projectPath, hooksDir, script); } if (spec.id === "deny-dangerous") { for (const script of LEGACY_DENY_DANGEROUS_SCRIPT_NAMES) { removeLegacyAgentScriptIfPresent(projectPath, hooksDir, script); } } } } function hookScriptContent(script) { const source = readFileSync(getTemplatePath(`workflow/hooks/${script}`), "utf-8"); return source; } function copyHookScripts(projectPath, agent, spec) { if (!agent.hooksDir) return; mkdirSync(join(projectPath, agent.hooksDir), { recursive: true }); for (const script of spec.scriptFiles) { const target = scriptTarget(projectPath, agent, script); writeFileAtomic(target, hookScriptContent(script), projectPath); chmodSync(target, 0o755); } ensureHookGitignoreEntries(projectPath); if (spec.id === "deny-dangerous") { const targetDir = join(projectPath, ".goat-flow", "hooks", "deny-dangerous"); mkdirSync(targetDir, { recursive: true }); for (const file of DENY_DANGEROUS_POLICY_FILES) { const source = getTemplatePath(`workflow/hooks/deny-dangerous/${file}`); const target = join(targetDir, file); assertWithinProject(projectPath, target); writeFileAtomic(target, readFileSync(source, "utf-8"), projectPath); chmodSync(target, 0o755); } for (const script of LEGACY_DENY_DANGEROUS_SCRIPT_NAMES) { removeScriptIfPresent(projectPath, agent, script); } } removeLegacyAgentHookScripts(projectPath, spec); } function removeScriptIfPresent(projectPath, agent, script) { const target = scriptTarget(projectPath, agent, script); try { unlinkSync(target); } catch { /* target already gone - script removal is idempotent, missing file is fine */ } } function removeHookScripts(projectPath, agent, spec) { removeScriptIfPresent(projectPath, agent, spec.primaryScript); if (spec.id === "deny-dangerous") { for (const script of LEGACY_DENY_DANGEROUS_SCRIPT_NAMES) { removeScriptIfPresent(projectPath, agent, script); } } removeLegacyAgentHookScripts(projectPath, spec); } /** Build the state payload for an agent that cannot host the requested hook. */ function unsupportedAgentHookState(reason) { return { supported: false, installed: false, scriptPath: null, configPath: null, reason, }; } function hookDrift(desired, installed) { if (desired && !installed) return "desired-on-actual-off"; if (!desired && installed) return "desired-off-actual-on"; return undefined; } function supportedAgentHookState(projectPath, agent, spec, desired) { const read = readAgentHookState(projectPath, agent, spec); const installed = read.installed && scriptExists(projectPath, agent, spec); const drift = hookDrift(desired, installed); return { supported: true, installed, scriptPath: agent.hooksDir ? `${agent.hooksDir}/${spec.primaryScript}`.replace(/\/+/gu, "/") : null, configPath: agent.hookConfigFile, ...(drift ? { drift } : {}), ...(read.configMissing ? { reason: "Hook config file is missing." } : {}), ...(read.configInvalid ? { reason: "Hook config file is invalid JSON." } : {}), }; } function agentHookState(projectPath, agent, spec, desired) { const unsupportedReason = unsupportedReasonForSpec(spec, agent); if (unsupportedReason) return unsupportedAgentHookState(unsupportedReason); if (!isSupportedAgent(agent)) { return unsupportedAgentHookState("Agent manifest has no hook directory or hook config file."); } return supportedAgentHookState(projectPath, agent, spec, desired); } /** Read persisted desired hook state, falling back to the registry default. */ function readDesired(projectPath, spec) { return readHookEnabled(projectPath, spec.id, spec.defaultEnabled); } /** * Remove leftover hook config entries from an agent the registry now marks * unsupported for this spec. Without this, flipping an * agent to unsupported strands dead registrations that agents may still * attempt to run. Cleanup intentionally does not trust current manifest event * metadata: a manifest can be corrected to remove a bogus event while stale * managed entries for that same event still exist on disk. * Scripts are shared across agents and stay untouched. */ function pruneUnsupportedAgentHookEntries(projectPath, agent, spec) { if (!isSupportedAgent(agent)) return; if (!hookConfigExists(projectPath, agent)) return; writeAgentHookState(projectPath, agent, spec, false); } function reconcileHook(projectPath, spec, enabled) { const profiles = getAgentProfiles(); for (const agent of profiles) { if (unsupportedReasonForSpec(spec, agent)) { pruneUnsupportedAgentHookEntries(projectPath, agent, spec); continue; } if (!isSupportedAgent(agent)) continue; if (!shouldReconcileAgent(projectPath, agent, spec, profiles)) continue; if (enabled) copyHookScripts(projectPath, agent, spec); else removeHookScripts(projectPath, agent, spec); if (enabled || hookConfigExists(projectPath, agent)) { writeAgentHookState(projectPath, agent, spec, enabled); } } } function pruneRemovedHookTombstone(projectPath, spec) { const profiles = getAgentProfiles(); for (const agent of profiles) { if (isSupportedAgent(agent) && hookConfigExists(projectPath, agent)) { writeAgentHookState(projectPath, agent, spec, false); } if (agent.hooksDir) removeHookScripts(projectPath, agent, spec); } } function pruneRemovedHookTombstones(projectPath) { for (const spec of REMOVED_HOOK_TOMBSTONES) { pruneRemovedHookTombstone(projectPath, spec); removeHookConfig(projectPath, spec.id); } removeTopLevelConfigBlock(projectPath, "plan-guard"); removeGoatFlowGitignoreEntry(projectPath, "logs/plan-guard-state.json"); } /** Snapshot one hook across all known agents for dashboard and CLI consumers. */ function readHookState(hookId, projectPath) { const spec = resolveSpec(hookId); const enabled = readDesired(projectPath, spec); const agents = Object.fromEntries(getAgentProfiles().map((agent) => [ agent.id, agentHookState(projectPath, agent, spec, enabled), ])); return { id: spec.id, name: spec.displayName, description: spec.description, togglable: spec.togglable, enabled, defaultEnabled: spec.defaultEnabled, requiresConfirmDialog: spec.requiresConfirmDialog, agents, }; } // Snapshots the current enabled/installed state of every known hook for one // project; reads settings + script presence, so the result reflects on-disk // reality, not the in-memory registry defaults. export function readAllHookStates(projectPath) { return listHookSpecs().map((spec) => readHookState(spec.id, projectPath)); } export function applyHookState(hookId, enabled, projectPath) { pruneRemovedHookTombstones(projectPath); const spec = resolveSpec(hookId); if (!spec.togglable) { throw new HookRegistrarError(`Hook is not togglable: ${hookId}`, 400); } setHookEnabled(projectPath, spec.id, enabled); reconcileHook(projectPath, spec, enabled); return readHookState(spec.id, projectPath); } // Side-effecting: rewrites each togglable hook's installed files to match its // persisted desired state, repairing drift (e.g. after a manual settings edit), // then returns the refreshed snapshot. Non-togglable hooks are left untouched. export function syncHookStates(projectPath) { pruneRemovedHookTombstones(projectPath); for (const spec of listHookSpecs()) { if (!spec.togglable) continue; reconcileHook(projectPath, spec, readDesired(projectPath, spec)); } return readAllHookStates(projectPath); } //# sourceMappingURL=hook-registrar.js.map