@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
JavaScript
/**
* 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