@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.
551 lines • 24.4 kB
JavaScript
import { AUDIT_VERSION, SKILL_NAMES } from "../constants.js";
import { collectCodexWorkspaceRootEntries } from "../facts/agent/settings.js";
import { agentDenyMechanism } from "./check-agent-deny-mechanism.js";
import { checkSelectedInstructionAvailable, specProvenance, uniquePaths, } from "./check-agent-common.js";
// === 1. Agent Instruction ===
/** Returns true if goat-flow-specific artifacts exist for an agent.
* A bare agent directory (e.g. `.claude/` from Claude Code) with only a
* settings file does NOT count - we require goat-flow skill directories
* or the guardrail hook scripts to distinguish goat-flow installs from the
* agent's own config. */
function agentArtifactsExist(fs, profile) {
const hooksDir = profile.hooks_dir?.replace(/\/$/, "");
if (hooksDir !== undefined &&
(fs.exists(`${hooksDir}/deny-dangerous.sh`) ||
fs.exists(`${hooksDir}/guard-repository-writes.sh`))) {
return true;
}
const skillsDir = profile.skills_dir.replace(/\/$/, "");
try {
const entries = fs.listDir(skillsDir);
if (entries.some((e) => SKILL_NAMES.includes(e)))
return true;
}
catch {
// listDir may throw if the directory doesn't exist
}
return false;
}
/** Check whether the selected agent has its instruction file installed. */
function checkInstructionPresent(ctx) {
const agentFacts = ctx.agents.find((agentFacts) => agentFacts.agent.id === ctx.agentFilter);
if (agentFacts?.instruction.exists)
return null;
// In --agent mode we look up the expected instruction path from the detected
// structure so the failure message stays specific even when the file is absent.
const profile = ctx.agentFilter
? ctx.structure.agents[ctx.agentFilter]
: undefined;
const instructionFile = profile?.instruction_file ?? `${ctx.agentFilter} instruction file`;
return {
check: "Agent instruction file",
message: `Missing: ${ctx.agentFilter} (${instructionFile})`,
howToFix: `Create ${instructionFile} by running \`goat-flow setup --agent ${ctx.agentFilter}\`.`,
};
}
/** Check supported managed agents whose primary instruction files are absent. */
function checkSupportedInstructionFilesPresent(ctx) {
const missing = ctx.agents
.filter((agentFacts) => !agentFacts.instruction.exists)
.map((agentFacts) => `${agentFacts.agent.id} (${agentFacts.agent.instructionFile})`);
if (missing.length === 0)
return null;
return {
check: "Agent instruction file",
message: `Supported agent instruction files missing: ${missing.join(", ")}`,
howToFix: "Run `goat-flow setup --agent <id>` for each missing agent, or use `goat-flow audit . --agent <id>` to scope the audit to one agent.",
};
}
/** Check that aggregate agent scope has at least one managed agent surface. */
function checkAnyAgentConfigured(ctx) {
if (ctx.agents.length > 0)
return null;
return {
check: "Agent instruction file",
message: "No supported agent instruction files found",
howToFix: "Run `goat-flow setup --agent <id>` for the agent this repo should manage, then complete the project-specific setup steps.",
};
}
/** Return a blocking failure for dependent per-agent checks when the primary
* instruction file is missing and no agent facts were extracted. */
function shouldCheckCopilotCommitInstructions(ctx) {
if (ctx.agentFilter !== null && ctx.agentFilter !== "copilot")
return false;
if (!ctx.fs.exists(".github"))
return false;
if (ctx.agentFilter === "copilot")
return true;
return ctx.structure.agents.copilot !== undefined;
}
/**
* Check whether the Copilot instruction file bridges to the canonical commit guide.
*
* IDEs (VS Code, JetBrains) auto-read .github/copilot-instructions.md but not
* docs/coding-standards/git-commit.md, so commit conventions only reach Copilot when the auto-read
* instruction file references the canonical doc. Returns null - no failure - when the .github/ dir
* is absent, when Copilot is not a configured agent in aggregate mode (a Claude/Codex project that
* happens to ship GitHub config must not be forced to add it), when the Copilot instruction file
* itself is missing (the broader instruction-file check owns that failure), or when the reference
* is already present.
*
* @param ctx - Audit context exposing the read-only filesystem, agent filter, and resolved structure.
* @returns An AuditFailure when the instruction file omits the commit-guide reference, otherwise null.
*/
function checkCopilotCommitInstructionsPresent(ctx) {
if (!shouldCheckCopilotCommitInstructions(ctx))
return null;
const copilotInstruction = ctx.structure.agents.copilot?.instruction_file ??
".github/copilot-instructions.md";
if (!ctx.fs.exists(copilotInstruction))
return null;
const commitGuide = "docs/coding-standards/git-commit.md";
if ((ctx.fs.readFile(copilotInstruction) ?? "").includes(commitGuide)) {
return null;
}
return {
check: "Agent instruction file",
message: `Missing: copilot (${copilotInstruction} must reference ${commitGuide})`,
evidence: copilotInstruction,
howToFix: `Add a ## Commit Messages section to ${copilotInstruction} that references ${commitGuide}, then rerun \`goat-flow audit --agent copilot\`.`,
};
}
/** Skills dirs owned by agents whose instruction file is present. */
function presentAgentSkillsDirs(ctx) {
const dirs = new Set();
for (const profile of Object.values(ctx.structure.agents)) {
if (profile.skills_dir && ctx.fs.exists(profile.instruction_file)) {
dirs.add(profile.skills_dir.replace(/\/$/, ""));
}
}
return dirs;
}
/** Check for agent artifacts that remain after their instruction file was removed. */
function checkOrphanedArtifacts(ctx) {
if (!ctx.config.exists)
return null;
const sharedDirs = presentAgentSkillsDirs(ctx);
const missing = [];
for (const [agentId, profile] of Object.entries(ctx.structure.agents)) {
if (ctx.fs.exists(profile.instruction_file))
continue;
const skillsDir = profile.skills_dir.replace(/\/$/, "");
if (skillsDir && sharedDirs.has(skillsDir))
continue;
if (agentArtifactsExist(ctx.fs, profile)) {
missing.push(`${agentId} (${profile.instruction_file})`);
}
}
if (missing.length === 0)
return null;
const noun = missing.length === 1 ? "file is" : "files are";
return {
check: "Agent instruction file",
message: `Agent artifacts exist but instruction ${noun} missing: ${missing.join(", ")}`,
howToFix: `Run \`goat-flow setup --agent <id>\` for each listed agent to recreate the instruction file, or remove the stale agent directories.`,
};
}
/** Return agent-specific provenance for the broad instruction-file check. */
function agentInstructionProvenance(ctx, failure) {
const paths = ["workflow/manifest.json", ".goat-flow/architecture.md"];
const failedAgentId = failure?.message.match(/\b([a-z]+) \([^)]+\)/)?.[1];
const agentId = ctx.agentFilter ?? failedAgentId;
const profile = agentId ? ctx.structure.agents[agentId] : undefined;
if (profile?.instruction_file)
paths.push(profile.instruction_file);
if (agentId === "copilot" ||
failure?.evidence === ".github/copilot-instructions.md") {
paths.push("workflow/setup/agents/copilot.md", ".github/copilot-instructions.md", "docs/coding-standards/git-commit.md");
}
return specProvenance(uniquePaths(paths));
}
const agentInstruction = {
id: "agent-instruction",
name: "Agent instruction file",
scope: "agent",
supportsAggregate: true,
provenance: specProvenance([
"workflow/manifest.json",
".goat-flow/architecture.md",
]),
provenanceFor: agentInstructionProvenance,
/** Run the Agent instruction file check. */
run: (ctx) => {
if (ctx.agentFilter) {
return (checkInstructionPresent(ctx) ??
checkCopilotCommitInstructionsPresent(ctx));
}
return (checkAnyAgentConfigured(ctx) ??
checkSupportedInstructionFilesPresent(ctx) ??
checkOrphanedArtifacts(ctx) ??
checkCopilotCommitInstructionsPresent(ctx));
},
};
// === 2. Agent Skills ===
/** Check canonical skills and references because every agent mirror must preserve the install contract. */
function checkCanonicalSkills(ctx) {
const canonical = ctx.structure.skills.canonical;
const missing = [];
const references = ctx.structure.skills.references ?? {};
for (const agentFacts of ctx.agents) {
for (const skill of canonical) {
const referenceFiles = Array.isArray(references[skill])
? references[skill].filter((file) => typeof file === "string")
: [];
for (const relativeFile of ["SKILL.md", ...referenceFiles]) {
const skillPath = `${agentFacts.agent.skillsDir}/${skill}/${relativeFile}`;
if (!ctx.fs.exists(skillPath)) {
missing.push(`${agentFacts.agent.id}:${skill}:${relativeFile}`);
}
}
}
}
if (missing.length === 0)
return null;
return {
check: "Agent skills",
message: `Missing skill files: ${missing.join(", ")}`,
evidence: missing[0],
howToFix: "Re-install skills by running `goat-flow install . --agent <id>` for the affected agent.",
};
}
/** Return the manifest-declared reference files for one skill, scoped to the references/ subtree. */
function expectedReferenceFiles(ctx, skill) {
const references = ctx.structure.skills.references ?? {};
const referenceFiles = Array.isArray(references[skill])
? references[skill].filter((file) => typeof file === "string" && file.startsWith("references/"))
: [];
return new Set(referenceFiles);
}
function checkUnexpectedSkillReferences(ctx) {
const unexpected = [];
for (const agentFacts of ctx.agents) {
for (const skill of ctx.structure.skills.canonical) {
const skillRoot = `${agentFacts.agent.skillsDir}/${skill}`;
const referencesDir = `${skillRoot}/references`;
if (!ctx.fs.exists(referencesDir))
continue;
const expected = expectedReferenceFiles(ctx, skill);
for (const path of ctx.fs.glob(`${referencesDir}/**/*.md`)) {
const prefix = `${skillRoot}/`;
const relativeFile = path.startsWith(prefix)
? path.slice(prefix.length)
: path;
if (!expected.has(relativeFile)) {
unexpected.push(`${agentFacts.agent.id}:${skill}:${relativeFile}`);
}
}
}
}
if (unexpected.length === 0)
return null;
return {
check: "Agent skills",
message: `Unexpected stale skill reference files found: ${unexpected.join(", ")}`,
evidence: unexpected[0],
howToFix: "Run `goat-flow install . --agent <id>` for the affected agent. The installer prunes manifest-unlisted skill reference files during upgrades.",
};
}
/** Check installed skill versions because outdated mirrors can silently use old workflow rules. */
function checkSkillVersions(ctx) {
const noVersion = [];
const mismatch = [];
for (const agentFacts of ctx.agents) {
for (const [name, version] of Object.entries(agentFacts.skills.versions)) {
if (version === null) {
noVersion.push(`${agentFacts.agent.id}:${name}`);
}
else if (version !== AUDIT_VERSION) {
mismatch.push(`${agentFacts.agent.id}:${name} (${version})`);
}
}
}
if (noVersion.length > 0) {
return {
check: "Agent skills",
message: `Missing goat-flow-skill-version: ${noVersion.join(", ")}`,
evidence: noVersion[0],
howToFix: "Re-install skills by running `goat-flow install . --agent <id>` for the affected agent.",
};
}
if (mismatch.length > 0) {
return {
check: "Agent skills",
message: `Version mismatch (expected ${AUDIT_VERSION}): ${mismatch.join(", ")}`,
evidence: mismatch[0],
howToFix: "Re-install skills by running `goat-flow install . --agent <id>` for the affected agent.",
};
}
return null;
}
/** Check stale skill directories because old names leave duplicate routing surfaces behind. */
function checkDeprecatedSkills(ctx) {
const staleNames = new Set(ctx.structure.skills.stale_names);
const found = [];
for (const agentFacts of ctx.agents) {
for (const dir of agentFacts.skills.installedDirs) {
const name = dir.split("/").pop() ?? "";
if (staleNames.has(name)) {
found.push(`${agentFacts.agent.id}:${name}`);
}
}
}
if (found.length === 0)
return null;
// Convert the compact agent:name identifiers back into filesystem paths so the
// remediation text points to concrete directories the user can remove.
const paths = found.map((s) => {
const [agent, name] = s.split(":");
const agentFacts = ctx.agents.find((a) => a.agent.id === agent);
return agentFacts ? `${agentFacts.agent.skillsDir}/${name}` : name;
});
return {
check: "Agent skills",
message: `Deprecated skill directories found: ${found.join(", ")}`,
evidence: found[0],
howToFix: `Remove the deprecated ${found.length === 1 ? "directory" : "directories"}: ${paths.join(", ")}. Delete the SKILL.md inside each, then remove the empty directory.`,
};
}
const agentSkills = {
id: "agent-skills",
name: "Agent skills",
scope: "agent",
provenance: specProvenance([
"workflow/manifest.json",
".goat-flow/learning-loop/footguns/skills.md",
]),
/** Run the Agent skills check. */
run: (ctx) => {
if (!ctx.agentFilter)
return null;
const blocked = checkSelectedInstructionAvailable(ctx, "Agent skills");
if (blocked)
return blocked;
return (checkCanonicalSkills(ctx) ??
checkUnexpectedSkillReferences(ctx) ??
checkSkillVersions(ctx) ??
checkDeprecatedSkills(ctx));
},
};
// === 3. Agent Settings ===
function settingsObject(parsed) {
return parsed && typeof parsed === "object"
? parsed
: null;
}
/** Check exact parsed settings keys because flattened TOML facts use dotted key names. */
function hasSettingsKey(parsed, key) {
const settings = settingsObject(parsed);
return settings ? Object.prototype.hasOwnProperty.call(settings, key) : false;
}
/** Read an explicit boolean setting without treating missing or mistyped values as false. */
function booleanSetting(parsed, key) {
const settings = settingsObject(parsed);
if (!settings)
return null;
const value = settings[key];
return typeof value === "boolean" ? value : null;
}
/** Report the old Codex hooks flag so installs migrate to the current feature name. */
function checkCodexDeprecatedHooksFlag(ctx) {
for (const agentFacts of ctx.agents) {
if (agentFacts.agent.id !== "codex")
continue;
if (!hasSettingsKey(agentFacts.settings.parsed, "features.codex_hooks"))
continue;
return {
check: "Agent settings",
message: "Deprecated Codex feature flag in .codex/config.toml: [features].codex_hooks",
evidence: agentFacts.agent.settingsFile ?? ".codex/config.toml",
howToFix: "Replace `codex_hooks` with `hooks` under `[features]`, or run `goat-flow install . --agent codex` to migrate the setting.",
};
}
return null;
}
/** Report installed Codex hooks that cannot run because the hooks feature flag is absent. */
function checkCodexHooksEnabled(ctx) {
for (const agentFacts of ctx.agents) {
if (agentFacts.agent.id !== "codex")
continue;
if (!agentFacts.hooks.denyExists && !agentFacts.hooks.denyIsRegistered)
continue;
if (booleanSetting(agentFacts.settings.parsed, "features.hooks") === true) {
continue;
}
return {
check: "Agent settings",
message: "Codex hooks are installed but .codex/config.toml does not enable [features].hooks = true",
evidence: agentFacts.agent.settingsFile ?? ".codex/config.toml",
howToFix: "Add `hooks = true` under `[features]` in .codex/config.toml, or run `goat-flow install . --agent codex` to install the current Codex settings template.",
};
}
return null;
}
/** Detect literal Codex workspace-root denies that should be expanded to subtree globs. */
function isCodexExactWorkspaceRootPath(pattern) {
return pattern !== "." && !pattern.includes("*") && !pattern.endsWith("/**");
}
/** Detect Codex none-mode globs that do not use the required subtree suffix. */
function isCodexInvalidNoneGlob(pattern) {
if (!pattern.includes("*"))
return false;
return !pattern.endsWith("/**");
}
function collectInvalidCodexInlineGlobs(rawValue, invalidGlobs) {
for (const [pattern, mode] of parseTomlInlineStringTableForKey(rawValue)) {
if (mode === "none" && isCodexInvalidNoneGlob(pattern)) {
invalidGlobs.push(pattern);
}
}
}
function codexFilesystemPatternFromKey(key, expandedRootPrefix, legacyExpandedRootPrefix) {
if (key.startsWith(expandedRootPrefix)) {
return key.slice(expandedRootPrefix.length);
}
if (key.startsWith(legacyExpandedRootPrefix)) {
return key.slice(legacyExpandedRootPrefix.length);
}
return null;
}
function collectCodexFilesystemEntryFindings(key, value, filesystemPrefix, legacyAnchor, invalidGlobs, legacyAnchors) {
if (!key.startsWith(filesystemPrefix))
return;
if (key === legacyAnchor || key.startsWith(`${legacyAnchor}.`)) {
legacyAnchors.push(":project_roots");
}
if (typeof value !== "string")
return;
const isInlineRoot = key === `${filesystemPrefix}:workspace_roots` || key === legacyAnchor;
if (isInlineRoot) {
collectInvalidCodexInlineGlobs(value, invalidGlobs);
return;
}
const pattern = codexFilesystemPatternFromKey(key, `${filesystemPrefix}:workspace_roots.`, `${legacyAnchor}.`);
if (pattern === null || value !== "none")
return;
if (isCodexInvalidNoneGlob(pattern)) {
invalidGlobs.push(pattern);
}
}
function collectCodexFilesystemFindings(parsed, profileName) {
const invalidGlobs = [];
const legacyAnchors = [];
if (!parsed || typeof parsed !== "object") {
return { invalidGlobs, legacyAnchors };
}
const filesystemPrefix = `permissions.${profileName}.filesystem.`;
const legacyAnchor = `${filesystemPrefix}:project_roots`;
for (const [key, value] of Object.entries(parsed)) {
collectCodexFilesystemEntryFindings(key, value, filesystemPrefix, legacyAnchor, invalidGlobs, legacyAnchors);
}
return { invalidGlobs, legacyAnchors };
}
function parseTomlInlineStringTableForKey(rawValue) {
const value = rawValue.trim();
if (!value.startsWith("{") || !value.endsWith("}"))
return [];
const entries = [];
const entryPattern = /"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"/gu;
for (const match of value.matchAll(entryPattern)) {
const [, key, mode] = match;
if (key && mode)
entries.push([key, mode]);
}
return entries;
}
function formatCodexWorkspaceRootInvalidGlobMessage(invalidGlobs, legacyAnchors) {
const messageParts = [];
if (invalidGlobs.length > 0) {
messageParts.push(`Codex permission profile uses filename-glob patterns with "none" access that Codex 0.131+ rejects: ${uniquePaths(invalidGlobs).join(", ")}`);
}
if (legacyAnchors.length > 0) {
messageParts.push(`Codex permission profile uses the legacy ":project_roots" anchor (Codex 0.131+ uses ":workspace_roots")`);
}
return `${messageParts.join("; ")}. Codex requires exact paths or trailing "/**" subtree patterns for "none" access.`;
}
function checkCodexWorkspaceRootInvalidGlobs(ctx) {
for (const agentFacts of ctx.agents) {
if (agentFacts.agent.id !== "codex")
continue;
const settings = settingsObject(agentFacts.settings.parsed);
const defaultPermissions = settings?.default_permissions;
if (typeof defaultPermissions !== "string" || defaultPermissions === "") {
continue;
}
const { invalidGlobs, legacyAnchors } = collectCodexFilesystemFindings(agentFacts.settings.parsed, defaultPermissions);
if (invalidGlobs.length === 0 && legacyAnchors.length === 0)
continue;
return {
check: "Agent settings",
message: formatCodexWorkspaceRootInvalidGlobMessage(invalidGlobs, legacyAnchors),
evidence: agentFacts.agent.settingsFile ?? ".codex/config.toml",
howToFix: "Run `goat-flow install . --agent codex` (without --force) to migrate the .codex/config.toml filesystem block in place. The installer rewrites filename globs to canonical subtree denies (e.g. `secrets/**`, `.ssh/**`). Filename-level protections are covered by .goat-flow/hooks/deny-dangerous.sh.",
};
}
return null;
}
function checkCodexWorkspaceRootExactPaths(ctx) {
for (const agentFacts of ctx.agents) {
if (agentFacts.agent.id !== "codex")
continue;
const settings = settingsObject(agentFacts.settings.parsed);
const defaultPermissions = settings?.default_permissions;
if (typeof defaultPermissions !== "string" || defaultPermissions === "") {
continue;
}
const missing = collectCodexWorkspaceRootEntries(agentFacts.settings.parsed, defaultPermissions)
.filter((entry) => isCodexExactWorkspaceRootPath(entry.pattern))
.map((entry) => entry.pattern)
.filter((pattern) => !ctx.fs.exists(pattern));
if (missing.length === 0)
continue;
return {
check: "Agent settings",
message: `Codex permission profile lists exact workspace-root paths that do not exist: ${uniquePaths(missing).join(", ")}`,
evidence: agentFacts.agent.settingsFile ?? ".codex/config.toml",
howToFix: "Remove absent exact entries from .codex/config.toml. Keep trailing `/**` subtree denies, and add exact `none`/`read` entries only for files that exist in this checkout.",
};
}
return null;
}
const agentSettings = {
id: "agent-settings",
name: "Agent settings",
scope: "agent",
provenance: specProvenance([
"workflow/manifest.json",
".goat-flow/architecture.md",
]),
/** Run the Agent settings check. */
run: (ctx) => {
if (!ctx.agentFilter)
return null;
const blocked = checkSelectedInstructionAvailable(ctx, "Agent settings");
if (blocked)
return blocked;
const invalid = [];
for (const agentFacts of ctx.agents) {
if (agentFacts.settings.exists && !agentFacts.settings.valid) {
invalid.push(agentFacts.agent.id);
}
}
if (invalid.length > 0) {
return {
check: "Agent settings",
message: `Invalid settings for: ${invalid.join(", ")}`,
howToFix: `Fix the JSON syntax in the settings file for ${invalid.join(", ")}.`,
};
}
return (checkCodexDeprecatedHooksFlag(ctx) ??
checkCodexHooksEnabled(ctx) ??
checkCodexWorkspaceRootInvalidGlobs(ctx) ??
checkCodexWorkspaceRootExactPaths(ctx));
},
};
/** 4 agent setup checks */
export const AGENT_CHECKS = [
agentInstruction,
agentSkills,
agentSettings,
agentDenyMechanism,
];
//# sourceMappingURL=check-agent-setup.js.map