@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.
300 lines • 13.2 kB
JavaScript
/**
* Audit checks for each agent's dangerous-command deny mechanism (concern 4). Verifies that a deny
* guard is present, that any hook scripts pass `bash -n`, and that deny patterns are registered -
* accepting both file-based and config-based mechanisms because agents satisfy the contract in
* different ways. Some checks spawn `bash` and copy fixture hooks to a real path, so this file owns
* the bridge from the in-memory audit FS to the actual workspace the shell needs. The runtime
* smoke that replays a blocked payload through configured launchers and the registered hook
* lives in check-agent-deny-runtime.ts; this file composes both halves into the BuildCheck.
*/
import * as childProcess from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { AUDIT_VERSION } from "../constants.js";
import { getTemplatePath } from "../paths.js";
import { checkSelectedInstructionAvailable, incidentProvenance, } from "./check-agent-common.js";
import { checkHookRuntimeSmoke, commandCompletedSuccessfully, evidencePath, spawnFailureFor, } from "./check-agent-deny-runtime.js";
// === 4. Agent Deny Mechanism ===
const LEGACY_DENY_HOOK_FILES = [
"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 DENY_HOOK_TEMPLATE_FILES = [
"deny-dangerous.sh",
"deny-dangerous/patterns-shell.sh",
"deny-dangerous/patterns-paths.sh",
"deny-dangerous/patterns-writes.sh",
"deny-dangerous/deny-dangerous-self-test.sh",
];
/** Check deny-hook presence because unsupported agents and config-based agents need different handling. */
function checkDenyHookPresent(ctx) {
for (const agentFacts of ctx.agents) {
// Capability-limited agents (e.g. Antigravity at v1.0.1) have no documented
// deny mechanism upstream. The manifest records this as
// `denyMechanism: null`; skip the check rather than producing a permanent
// audit failure that downstream projects cannot fix.
if (agentFacts.agent.denyMechanism === null)
continue;
if (!agentFacts.hooks.denyExists && !agentFacts.hooks.denyIsConfigBased) {
return {
check: "Agent deny mechanism",
message: `Missing deny mechanism for ${agentFacts.agent.id}`,
howToFix: "Create a deny hook file or add deny patterns to the agent's settings file.",
};
}
}
return null;
}
/** List shell hook files and swallows unreadable fixture dirs the same way the audit check always has. */
function listShellHookFiles(ctx, hooksDir) {
try {
return ctx.fs.listDir(hooksDir).filter((file) => file.endsWith(".sh"));
}
catch {
return [];
}
}
/** Spawn bash syntax validation for one hook and map process failures into audit evidence. */
function checkHookFileSyntax(ctx, hooksDir, file) {
const hookPath = `${hooksDir}/${file}`;
// ctx.fs may be backed by an in-memory fixture, but bash -n needs a real workspace path.
const fullPath = join(ctx.projectPath, hooksDir, file);
try {
childProcess.execFileSync("bash", ["-n", fullPath], {
stdio: "pipe",
timeout: 5000,
});
return { status: "ok" };
}
catch (error) {
if (commandCompletedSuccessfully(error))
return { status: "ok" };
const spawnFailure = spawnFailureFor(error, `bash syntax check for ${hookPath}`);
if (spawnFailure !== null) {
return {
status: "spawn-failure",
failure: {
check: "Agent deny mechanism",
message: spawnFailure.message,
evidence: evidencePath(hookPath),
howToFix: spawnFailure.howToFix,
},
};
}
return { status: "syntax-error", path: hookPath };
}
}
/** Check shell syntax; spawns bash and recover from unreadable hook dirs because fixtures may be partial. */
function checkHookSyntax(ctx) {
const failures = [];
for (const agentFacts of ctx.agents) {
if (!agentFacts.agent.hooksDir)
continue;
const hooksDir = agentFacts.agent.hooksDir;
for (const file of listShellHookFiles(ctx, hooksDir)) {
const result = checkHookFileSyntax(ctx, hooksDir, file);
if (result.status === "spawn-failure")
return result.failure;
if (result.status === "syntax-error")
failures.push(result.path);
}
}
if (failures.length === 0)
return null;
return {
check: "Agent deny mechanism",
message: `bash -n failed: ${failures.join(", ")}`,
evidence: failures[0],
howToFix: `Fix the bash syntax errors in ${failures.join(", ")}. Run \`bash -n <file>\` to see details.`,
};
}
/** Check deny-pattern registration because config and hook based agents satisfy the contract differently. */
function checkDenyPatterns(ctx) {
for (const agentFacts of ctx.agents) {
// Skip agents with no documented project-local deny mechanism.
if (agentFacts.agent.denyMechanism === null)
continue;
if (!agentFacts.settings.hasDenyPatterns && !agentFacts.hooks.denyExists) {
return {
check: "Agent deny mechanism",
message: `No deny patterns registered for ${agentFacts.agent.id}`,
howToFix: "Register deny patterns in the agent's settings file or create a deny hook script in the agent's hooks directory.",
};
}
}
return null;
}
function checkLegacyHookDrift(ctx, agentId, hooksDir) {
const candidateDirs = [
hooksDir,
".claude/hooks",
".codex/hooks",
".agents/hooks",
".github/hooks",
];
for (const candidateDir of candidateDirs) {
for (const legacyFile of LEGACY_DENY_HOOK_FILES) {
const legacyRelPath = join(candidateDir, legacyFile);
if (ctx.fs.readFile(legacyRelPath) !== null) {
return {
check: "Agent deny mechanism",
message: `${legacyFile} is a legacy guardrail hook for ${agentId}; migrate to deny-dangerous.sh`,
evidence: evidencePath(legacyRelPath),
howToFix: `Re-run \`npx /goat-flow@${AUDIT_VERSION} install . --agent ${agentId}\` to remove legacy guard hooks and install deny-dangerous.sh.`,
};
}
}
}
return null;
}
/**
* Read a canonical hook template's text from the packaged `workflow/hooks/` tree.
*
* Swallows read errors and returns null as a fallback when the template is absent or
* unreadable, so drift checks can treat "no canonical template" and "installed copy
* differs" as distinct, non-fatal outcomes instead of aborting the whole audit.
*
* @param templateFile - path under `workflow/hooks/` (e.g. `deny-dangerous.sh` or `deny-dangerous/patterns-shell.sh`)
* @returns the template's UTF-8 contents, or null when the file is missing or unreadable
*/
function readHookTemplateContent(templateFile) {
const templatePath = getTemplatePath(`workflow/hooks/${templateFile}`);
if (!existsSync(templatePath))
return null;
try {
return readFileSync(templatePath, "utf-8");
}
catch {
return null;
}
}
function installedTemplateRelPath(hooksDir, templateFile) {
return templateFile.startsWith("deny-dangerous/")
? join(".goat-flow", "hooks", templateFile)
: join(hooksDir, templateFile);
}
function checkTemplateDrift(ctx, agentId, hooksDir) {
for (const templateFile of DENY_HOOK_TEMPLATE_FILES) {
const templateContent = readHookTemplateContent(templateFile);
if (templateContent === null)
continue;
const installedRelPath = installedTemplateRelPath(hooksDir, templateFile);
const installed = ctx.fs.readFile(installedRelPath);
if (installed === null) {
return {
check: "Agent deny mechanism",
message: `${templateFile} is missing for ${agentId}`,
evidence: evidencePath(installedRelPath),
howToFix: `Re-run \`npx /goat-flow@${AUDIT_VERSION} install . --agent ${agentId}\` to update the hook files.`,
};
}
if (installed.trimEnd() !== templateContent.trimEnd()) {
return {
check: "Agent deny mechanism",
message: `${templateFile} for ${agentId} differs from the current goat-flow template (v${AUDIT_VERSION})`,
evidence: evidencePath(installedRelPath),
howToFix: `Re-run \`npx /goat-flow@${AUDIT_VERSION} install . --agent ${agentId}\` to update the hook files.`,
};
}
}
return null;
}
/** Compare installed deny hooks against templates; recover from missing templates because installs may be partial. */
function checkHookVersion(ctx) {
for (const agentFacts of ctx.agents) {
const hooksDir = agentFacts.agent.hooksDir;
if (!hooksDir)
continue;
const legacyFailure = checkLegacyHookDrift(ctx, agentFacts.agent.id, hooksDir);
if (legacyFailure)
return legacyFailure;
const denyRelPath = join(hooksDir, "deny-dangerous.sh");
if (ctx.fs.readFile(denyRelPath) === null)
continue;
const templateFailure = checkTemplateDrift(ctx, agentFacts.agent.id, hooksDir);
if (templateFailure)
return templateFailure;
}
return null;
}
/** Run each deny hook self-test; spawns bash and reports failures because static registration is insufficient. */
function checkHookSelfTest(ctx) {
for (const agentFacts of ctx.agents) {
if (!agentFacts.agent.hooksDir)
continue;
const denyRelPath = join(".goat-flow", "hooks", "deny-dangerous", "deny-dangerous-self-test.sh");
const content = ctx.fs.readFile(denyRelPath);
// Config-based deny rules satisfy the deny-mechanism requirement, but only an
// on-disk shell hook can run the registered self-test.
if (content === null)
continue;
const denyPath = join(ctx.projectPath, denyRelPath);
const dispatcherRelPath = join(agentFacts.agent.hooksDir, "deny-dangerous.sh");
const dispatcherPath = join(ctx.projectPath, dispatcherRelPath);
const env = ctx.fs.readFile(dispatcherRelPath) === null
? process.env
: { ...process.env, GOAT_DENY_DANGEROUS_HOOK: dispatcherPath };
try {
childProcess.execFileSync("bash", [denyPath, "--self-test=smoke"], {
env,
stdio: "pipe",
timeout: 30000,
});
}
catch (error) {
if (commandCompletedSuccessfully(error))
continue;
const spawnFailure = spawnFailureFor(error, `deny-dangerous self-test for ${agentFacts.agent.id}`);
if (spawnFailure !== null) {
return {
check: "Agent deny mechanism",
message: spawnFailure.message,
evidence: evidencePath(denyRelPath),
howToFix: spawnFailure.howToFix,
};
}
return {
check: "Agent deny mechanism",
message: `deny-dangerous-self-test.sh --self-test=smoke failed for ${agentFacts.agent.id}`,
evidence: evidencePath(denyRelPath),
howToFix: "Run `bash .goat-flow/hooks/deny-dangerous/deny-dangerous-self-test.sh --self-test=smoke` to see which cases fail.",
};
}
}
return null;
}
export const agentDenyMechanism = {
id: "agent-guardrails",
name: "Agent deny mechanism",
scope: "agent",
provenance: incidentProvenance([
".goat-flow/learning-loop/footguns/auditor.md",
".goat-flow/learning-loop/footguns/hooks.md",
]),
/** Run the Agent deny mechanism check. */
run: (ctx) => {
if (!ctx.agentFilter)
return null;
const blocked = checkSelectedInstructionAvailable(ctx, "Agent deny mechanism");
if (blocked)
return blocked;
if (ctx.denyMechanismEvidenceLevel === "present-only") {
return checkDenyHookPresent(ctx);
}
// Order the checks from cheapest/static to most expensive/runtime so we stop on
// the clearest failure before attempting shell execution.
const staticFailure = checkDenyHookPresent(ctx) ??
checkHookSyntax(ctx) ??
checkDenyPatterns(ctx) ??
checkHookVersion(ctx);
if (ctx.denyMechanismEvidenceLevel === "static") {
return staticFailure;
}
return (staticFailure ?? checkHookSelfTest(ctx) ?? checkHookRuntimeSmoke(ctx));
},
};
//# sourceMappingURL=check-agent-deny-mechanism.js.map