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.

273 lines 11.7 kB
/** * Check whether the agent's deny mechanism blocks git commit and/or git push. * * @param fs - project filesystem adapter used to read settings or hook scripts * @param agent - agent profile whose deny mechanism should be inspected * @returns git operation coverage detected from the agent's configured guardrail path */ export function checkDenyPatterns(fs, agent) { /** Deny mechanism configuration for this agent */ const deny = agent.denyMechanism; if (deny === null) { return { gitCommitBlocked: false, gitPushBlocked: false }; } if (deny.type === "settings-deny") { /** Parsed JSON from the settings deny file */ const parsed = fs.readJson(deny.path); if (parsed == null) return { gitCommitBlocked: false, gitPushBlocked: false }; /** Permissions object from the parsed settings */ const permissions = parsed.permissions; /** Raw deny array from permissions */ const rawDeny = permissions?.deny; /** Deny patterns as a string array, filtering non-strings for safety */ const denyList = Array.isArray(rawDeny) ? rawDeny.filter((p) => typeof p === "string") : []; return { gitCommitBlocked: denyList.some((p) => p.includes("git commit")), gitPushBlocked: denyList.some((p) => p.includes("git push")), }; } if (deny.type === "deny-script") { /** Content of the deny hook script */ const content = fs.readFile(deny.path); if (content == null) return { gitCommitBlocked: false, gitPushBlocked: false }; return { gitCommitBlocked: /git\s+commit/i.test(content), gitPushBlocked: /git\s+push/i.test(content), }; } // The 'both' mechanism blocks when either the settings file or the deny // script matches. /** Deny results from the settings-based mechanism */ const settings = checkDenyPatterns(fs, { ...agent, denyMechanism: { type: "settings-deny", path: deny.settingsPath }, }); /** Deny results from the script-based mechanism */ const script = checkDenyPatterns(fs, { ...agent, denyMechanism: { type: "deny-script", path: deny.scriptPath }, }); return { gitCommitBlocked: settings.gitCommitBlocked || script.gitCommitBlocked, gitPushBlocked: settings.gitPushBlocked || script.gitPushBlocked, }; } /** Extract settings facts from supported agent config formats. */ // eslint-disable-next-line complexity -- intentional multi-format settings extraction requires branching. export function extractSettingsFacts(fs, agent) { /** Whether the agent's settings file exists on disk */ const exists = agent.settingsFile ? fs.exists(agent.settingsFile) : false; let isSettingsValid = false; let parsed = null; let hasDenyPatterns = false; if (agent.settingsFile) { if (agent.settingsFile.endsWith(".toml")) { // TOML (Codex config.toml) -- parse key=value pairs into a flattened object const tomlContent = fs.readFile(agent.settingsFile); if (tomlContent) { const tomlObj = {}; let currentSection = ""; for (const line of tomlContent.split("\n")) { const trimmed = line.trim(); if (trimmed.startsWith("#") || trimmed === "") continue; const sectionMatch = trimmed.match(/^\[(.+)\]$/); if (sectionMatch?.[1]) { currentSection = normalizeTomlDottedKey(sectionMatch[1]); continue; } const kvMatch = trimmed.match(/^((?:"(?:\\.|[^"\\])*")|[\w.-]+)\s*=\s*(.+)$/); if (kvMatch?.[1] && kvMatch[2]) { const key = currentSection ? `${currentSection}.${normalizeTomlKey(kvMatch[1])}` : normalizeTomlKey(kvMatch[1]); const val = parseTomlScalar(kvMatch[2]); tomlObj[key] = val; } } isSettingsValid = Object.keys(tomlObj).length > 0; parsed = tomlObj; } } else { parsed = fs.readJson(agent.settingsFile); isSettingsValid = parsed !== null; } if (isSettingsValid && parsed) { /** Permissions object from the parsed settings */ const perms = parsed.permissions; /** Raw deny array from permissions */ const denyArr = perms?.deny; hasDenyPatterns = Array.isArray(denyArr) && denyArr.length > 0; } } // Require deny coverage for the common secret-bearing paths goat-flow cares about. const readDenyCoversSecrets = checkReadDenyCoversSecrets(parsed, hasDenyPatterns, fs); return { exists, valid: isSettingsValid, parsed, hasDenyPatterns, readDenyCoversSecrets, }; } /** Remove simple TOML string-key quoting for keys goat-flow needs to inspect. */ function normalizeTomlKey(rawKey) { const key = rawKey.trim(); if (key.startsWith('"') && key.endsWith('"')) return key.slice(1, -1); return key; } /** Normalize a dotted TOML table key while preserving quoted path/glob parts. */ function normalizeTomlDottedKey(rawKey) { return rawKey.split(".").map(normalizeTomlKey).join("."); } /** Parse the simple scalar values used in Codex config.toml. */ function parseTomlScalar(rawValue) { const value = rawValue.trim(); if (value === "true") return true; if (value === "false") return false; if (/^-?\d+$/u.test(value)) return Number(value); if (value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1); return value; } /** Require settings-based read denies for the main secret and credential path families. */ function checkReadDenyCoversSecrets(parsed, hasDenyPatterns, fs) { if (checkCodexPermissionProfileCoversSecrets(parsed, fs)) return true; if (!hasDenyPatterns || !parsed) return false; /** Permissions object from the parsed settings */ const perms = parsed.permissions; /** Raw deny array from permissions */ const denyArr = perms?.deny; if (!Array.isArray(denyArr)) return false; /** All deny patterns concatenated into a single string for regex matching */ const denyStr = denyArr.join(" "); /** Whether .env paths are covered by deny rules */ const hasEnv = /Read\(.*\.env/.test(denyStr); /** Whether .ssh paths are covered by deny rules */ const hasSsh = /Read\(.*\.ssh/.test(denyStr); /** Whether .aws paths are covered by deny rules */ const hasAws = /Read\(.*\.aws/.test(denyStr); /** Whether key/credential paths are covered by deny rules */ const hasKeys = /Read\(.*\.(pem|key|pfx)\b/.test(denyStr) || /Read\(.*credentials/.test(denyStr); return hasEnv && hasSsh && hasAws && hasKeys; } /** Detect Codex TOML permission profiles that deny the main secret path families. */ function checkCodexPermissionProfileCoversSecrets(parsed, fs) { if (!parsed || typeof parsed !== "object") return false; const defaultPermissions = parsed .default_permissions; if (typeof defaultPermissions !== "string" || defaultPermissions === "") { return false; } const denied = collectCodexDeniedWorkspaceRootPatterns(parsed, defaultPermissions); if (denied.size === 0) return false; return (hasCodexEnvDeny(denied, fs) && hasAnyCodexPattern(denied, ["secrets/**", "**/secrets/**"]) && hasAnyCodexPattern(denied, [".ssh/**", "**/.ssh/**"]) && hasAnyCodexPattern(denied, [".aws/**", "**/.aws/**"]) && hasCodexCredentialRootDeny(denied, fs)); } function collectCodexWorkspaceRootEntry(key, value, inlineTableKey, prefix) { if (key === inlineTableKey && typeof value === "string") { return parseTomlInlineStringTable(value).map(([pattern, mode]) => ({ pattern, mode, })); } if (typeof value !== "string" || !key.startsWith(prefix)) return []; const pattern = key.slice(prefix.length); return pattern ? [{ pattern, mode: value }] : []; } export function collectCodexWorkspaceRootEntries(parsed, profileName) { if (!parsed || typeof parsed !== "object") return []; const rootToken = ":workspace_roots"; const inlineTableKey = `permissions.${profileName}.filesystem.${rootToken}`; const prefix = `${inlineTableKey}.`; return Object.entries(parsed).flatMap(([key, value]) => collectCodexWorkspaceRootEntry(key, value, inlineTableKey, prefix)); } function collectCodexDeniedWorkspaceRootPatterns(parsed, profileName) { const denied = new Set(); for (const { pattern, mode } of collectCodexWorkspaceRootEntries(parsed, profileName)) { if (isCodexDenyMode(mode)) denied.add(pattern); } return denied; } /** Parse the single-line TOML inline string table shape Codex accepts. */ function parseTomlInlineStringTable(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; } /** Return whether any required Codex filesystem pattern is denied. */ function hasAnyCodexPattern(denied, patterns) { return patterns.some((pattern) => denied.has(pattern)); } /** Return whether every required Codex filesystem pattern is denied. */ function hasEveryCodexPattern(denied, patterns) { return patterns.every((pattern) => denied.has(pattern)); } /** Recognize both old goat-flow "none" entries and current Codex "deny" entries. */ function isCodexDenyMode(mode) { return mode === "none" || mode === "deny"; } /** Detect root .env files and variants. Codex broad denies also deny .env.example. */ function existingExactPathsAreDenied(denied, fs, patterns) { return patterns .filter((pattern) => fs.exists(pattern)) .every((pattern) => denied.has(pattern)); } /** Confirm Codex denies every root env file that actually exists in the target project. */ function hasCodexEnvDeny(denied, fs) { const exactEnvPaths = [ ".env", ".env.local", ".env.development", ".env.production", ".env.staging", ".env.test", ".envrc", ]; return (hasAnyCodexPattern(denied, ["**/.env*"]) || hasEveryCodexPattern(denied, exactEnvPaths.map((pattern) => `**/${pattern}`)) || existingExactPathsAreDenied(denied, fs, exactEnvPaths)); } /** Detect exact root/subtree credential surfaces Codex can express on 0.131+. */ function hasCodexCredentialRootDeny(denied, fs) { return ([ [".docker/**", "**/.docker/**"], [".gnupg/**", "**/.gnupg/**"], [".kube/**", "**/.kube/**"], ].every((patternGroup) => hasAnyCodexPattern(denied, patternGroup)) && hasAnyCodexPattern(denied, ["**/credentials*"]) && (hasEveryCodexPattern(denied, ["**/.npmrc", "**/.pypirc"]) || existingExactPathsAreDenied(denied, fs, [".npmrc", ".pypirc"])) && ["pem", "key", "pfx"].every((extension) => hasAnyCodexPattern(denied, [`**/*.${extension}`, `*.${extension}`]))); } //# sourceMappingURL=settings.js.map