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.

300 lines 13.2 kB
/** * 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 @blundergoat/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 @blundergoat/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 @blundergoat/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