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.

332 lines 13.5 kB
/** * Agent-specific hook registration readers/writers. * * The registrar owns script files and desired state; this module owns the * four JSON shapes used by Claude, Codex, Antigravity, and Copilot hook config * files. */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { writeFileAtomic } from "./safe-exec.js"; 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 LEGACY_DENY_DANGEROUS_HOOK_IDS = [ "guard-destructive-shell", "guard-secret-paths", "guard-repository-writes", ]; /** * Type guard for a JSON object - the only shape we can safely read keyed properties off. Excludes the two * `typeof x === "object"` footguns, `null` and arrays, so callers can treat untrusted `JSON.parse` output * as a record without crashing on `null.foo` or silently mis-reading an array as a map. Centralised because * the writer parses pre-existing agent config files that may legally contain any JSON value. * * @param value - parsed JSON of unknown shape (e.g. JSON.parse output) to test * @returns true - when value is a non-null, non-array object, narrowed to JsonObject */ function isObject(value) { return (value !== null && typeof value === "object" && Array.isArray(value) === false); } /** Resolve the agent hook config file; throws when the profile does not support hook writes. */ function configPath(projectPath, agent) { if (!agent.hookConfigFile) { throw new Error(`${agent.id} has no hook config file`); } return join(projectPath, agent.hookConfigFile); } /** Read an existing agent hook config; malformed JSON uses an empty-object fallback with `invalid=true`. */ function readJsonFile(path) { if (!existsSync(path)) return { value: {}, missing: true, invalid: false }; try { const parsed = JSON.parse(readFileSync(path, "utf-8")); return { value: isObject(parsed) ? parsed : {}, missing: false, invalid: !isObject(parsed), }; } catch { return { value: {}, missing: false, invalid: true }; } } /** Map goat-flow hook events to the event-key spelling required by each agent config format. */ function hookEventKey(agent, spec) { if (agent.id === "copilot") { return spec.event === "PreToolUse" ? "preToolUse" : "postToolUse"; } return spec.event; } /** Ensure the shared hooks container is an object before mutating event arrays inside it. */ function ensureHooksObject(config) { if (!isObject(config.hooks)) config.hooks = {}; return config.hooks; } /** Return the mutable event-entry array, creating it when an agent config lacks the event key. */ function eventEntries(config, event) { const hooks = ensureHooksObject(config); if (!Array.isArray(hooks[event])) hooks[event] = []; return hooks[event]; } /** Split pipe-delimited matcher strings because Claude and Codex store one matcher per entry. */ function matcherParts(matcher) { return matcher .split("|") .map((part) => part.trim()) .filter(Boolean); } /** Build the repo-relative hook script path stored in agent config files; throws for unsupported agents. */ function commandPath(agent, script) { if (!agent.hooksDir) throw new Error(`${agent.id} has no hooks dir`); return `${agent.hooksDir}/${script}`.replace(/\/+/gu, "/"); } /** Quote one script for `bash -c` without leaving shell metacharacters active. */ function shellSingleQuote(value) { return `'${value.split("'").join("'\\''")}'`; } /** Build the shell command variant that matches each agent's hook response protocol. */ function shellCommand(agent, spec) { const path = commandPath(agent, spec.primaryScript); const unavailable = spec.id === "gruff-code-quality" ? `{ printf 'gruff-code-quality: hook unavailable: git repository root or hook script unavailable; skipped.\\n' >&2; exit 0; }` : spec.id === "post-turn-safety" ? `{ printf 'post-turn-safety: hook unavailable: git repository root or hook script unavailable.\\n' >&2; exit 2; }` : agent.id === "antigravity" ? `{ printf '{"decision":"deny","reason":"Policy hook unavailable: git repository root unavailable."}\\n'; exit 0; }` : `{ printf 'BLOCKED: Policy hook unavailable: git repository root unavailable.\\n' >&2; exit 2; }`; // Central hook scripts live in the active worktree under .goat-flow/hooks. // Resolve the active tree first so linked worktrees run the policy checked out // beside the files being edited. Claude/Antigravity also fall back to // Claude's project-root env when a session has cd'd outside any git checkout; // Codex has no documented equivalent, so it stays fail-closed outside git. // The launcher cd's into the resolved root because deny-dangerous.sh resolves // its policy store from cwd; cd failure uses the same hook-specific // unavailable behavior. const resolveRoot = `root="$(git rev-parse --show-toplevel 2>/dev/null || true)"`; const claudeRootFallback = agent.id === "codex" ? "" : `; [ -f "$root/${path}" ] || root="\${CLAUDE_PROJECT_DIR:-}"`; const ensureRoot = `[ -f "$root/${path}" ] || ${unavailable}`; const script = `${resolveRoot}${claudeRootFallback}; ${ensureRoot}; cd "$root" || ${unavailable}; bash "$root/${path}"`; return `bash -c ${shellSingleQuote(script)}`; } /** Build Copilot's Windows hook command with a denial response when bash is unavailable. */ function powershellCommand(agent, spec) { const path = commandPath(agent, spec.primaryScript); return `if (Get-Command bash -ErrorAction SilentlyContinue) { bash ${path} } else { Write-Output '{"permissionDecision":"deny","permissionDecisionReason":"Bash, Git Bash, or WSL is required to run ${path} on Windows."}' }`; } /** Detect any existing hook entry that already points at one of the spec's managed scripts. */ function entryReferencesSpec(entry, spec) { if (!isObject(entry)) return false; const commands = [ typeof entry.command === "string" ? entry.command : "", typeof entry.bash === "string" ? entry.bash : "", typeof entry.powershell === "string" ? entry.powershell : "", ].join("\n"); if (spec.scriptFiles.some((script) => commands.includes(script))) return true; if (spec.id === "deny-dangerous" && LEGACY_DENY_DANGEROUS_SCRIPT_NAMES.some((script) => commands.includes(script))) { return true; } if (Array.isArray(entry.hooks)) { return entry.hooks.some((hook) => entryReferencesSpec(hook, spec)); } return false; } /** Translate generic hook matchers into Antigravity's tool names while leaving other agents unchanged. */ function matcherForAgent(agent, spec) { if (spec.event === "Stop") return ""; if (agent.id !== "antigravity") return spec.matcher; if (spec.id === "gruff-code-quality") { return [ "write_to_file", "replace_file_content", "multi_replace_file_content", ].join("|"); } if (spec.id === "deny-dangerous") { return [ "run_command", "view_file", "write_to_file", "replace_file_content", "multi_replace_file_content", ].join("|"); } return spec.matcher; } /** Remove only goat-flow-managed hook entries so unrelated user hook config is preserved. */ function removeHookEntries(config, event, spec) { const entries = eventEntries(config, event); const next = entries.filter((entry) => !entryReferencesSpec(entry, spec)); const hooks = ensureHooksObject(config); if (next.length === 0) { hooks[event] = undefined; return; } hooks[event] = next; } /** Create the Claude/Codex hook entries for each matcher segment in the managed spec. */ function claudeCodexEntries(agent, spec) { if (spec.event === "Stop") { const command = { type: "command", command: shellCommand(agent, spec), }; if (agent.id === "claude" && spec.timeoutSec !== undefined) { command.timeout = spec.timeoutSec; } if (agent.id === "codex") command.statusMessage = spec.displayName; return [{ hooks: [command] }]; } return matcherParts(spec.matcher).map((matcher) => { const command = { type: "command", command: shellCommand(agent, spec), }; // Codex's hook schema carries no timeout field, so only Claude gets the override. if (agent.id === "claude" && spec.timeoutSec !== undefined) { command.timeout = spec.timeoutSec; } if (agent.id === "codex") command.statusMessage = spec.displayName; return { matcher, hooks: [command], }; }); } /** Create Copilot's single hook entry shape with both bash and PowerShell commands. */ function copilotEntry(agent, spec) { return { type: "command", bash: commandPath(agent, spec.primaryScript), powershell: powershellCommand(agent, spec), timeoutSec: spec.timeoutSec ?? 30, }; } function antigravityHookDefinition(agent, spec) { const command = { type: "command", command: shellCommand(agent, spec), timeout: spec.timeoutSec ?? 30, }; if (spec.event === "Stop") { return { enabled: true, [hookEventKey(agent, spec)]: [ { hooks: [command], }, ], }; } return { enabled: true, [hookEventKey(agent, spec)]: [ { matcher: matcherForAgent(agent, spec), hooks: [command], }, ], }; } function appendHookEntries(config, agent, spec) { if (agent.id === "antigravity") { config[spec.id] = antigravityHookDefinition(agent, spec); return; } const event = hookEventKey(agent, spec); const entries = eventEntries(config, event); if (agent.id === "copilot") { if (typeof config.version !== "number") config.version = 1; entries.push(copilotEntry(agent, spec)); return; } entries.push(...claudeCodexEntries(agent, spec)); } function hasAntigravityExpectedEntries(config, agent, spec) { const definition = config[spec.id]; if (!isObject(definition) || definition.enabled === false) return false; const entries = definition[hookEventKey(agent, spec)]; if (!Array.isArray(entries)) return false; if (spec.event === "Stop") { return entries.some((entry) => isObject(entry) && entryReferencesSpec(entry, spec)); } return entries.some((entry) => isObject(entry) && entry.matcher === matcherForAgent(agent, spec) && entryReferencesSpec(entry, spec)); } function hasEventExpectedEntries(config, agent, spec) { const hooks = isObject(config.hooks) ? config.hooks : {}; const entries = hooks[hookEventKey(agent, spec)]; if (!Array.isArray(entries)) return false; if (spec.event === "Stop") { return entries.some((entry) => entryReferencesSpec(entry, spec)); } if (agent.id === "copilot") { return entries.some((entry) => entryReferencesSpec(entry, spec)); } return matcherParts(spec.matcher).every((matcher) => entries.some((entry) => isObject(entry) && entry.matcher === matcher && entryReferencesSpec(entry, spec))); } function hasAllExpectedEntries(config, agent, spec) { if (agent.id === "antigravity") { return hasAntigravityExpectedEntries(config, agent, spec); } return hasEventExpectedEntries(config, agent, spec); } export function readAgentHookState(projectPath, agent, spec) { const config = readJsonFile(configPath(projectPath, agent)); if (config.missing) return { installed: false, configMissing: true }; if (config.invalid) return { installed: false, configInvalid: true }; return { installed: hasAllExpectedEntries(config.value, agent, spec) }; } export function writeAgentHookState(projectPath, agent, spec, enabled) { const path = configPath(projectPath, agent); const config = readJsonFile(path); if (config.invalid) { throw new Error(`${agent.id} hook config is not valid JSON: ${agent.hookConfigFile}`); } const event = hookEventKey(agent, spec); if (agent.id === "antigravity") { Reflect.deleteProperty(config.value, spec.id); if (spec.id === "deny-dangerous") { for (const legacyId of LEGACY_DENY_DANGEROUS_HOOK_IDS) { Reflect.deleteProperty(config.value, legacyId); } } if (enabled) appendHookEntries(config.value, agent, spec); writeFileAtomic(path, `${JSON.stringify(config.value, null, 2)}\n`, projectPath); return; } removeHookEntries(config.value, event, spec); if (enabled) appendHookEntries(config.value, agent, spec); writeFileAtomic(path, `${JSON.stringify(config.value, null, 2)}\n`, projectPath); } //# sourceMappingURL=agent-hook-writer.js.map