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