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.

372 lines 16.1 kB
import { buildDenyRegistration, buildHookRegistration, readHookConfig, } from "./hook-registration.js"; /** Regex matching common lint, typecheck, test, and format-check tool invocations. */ const POST_TURN_VALIDATION_COMMAND_PATTERN = /\b(shellcheck|eslint|tsc|phpstan|ruff|mypy|flake8|rubocop|stylelint|ktlint|swiftlint)\b|biome\s+check|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:lint|typecheck|test(?::[A-Za-z0-9:_-]+)?|format(?::check)?)\b|cargo\s+check|go\s+vet|prettier\s+--check|bash\s+-n\b|(?:^|\s)(?:bash\s+)?(?:\.\/)?scripts\/preflight-checks\.sh\b/i; const LEGACY_GUARDRAIL_HOOK_FILES = [ "guard-common.sh", "guard-destructive-shell.sh", "guard-secret-paths.sh", "guard-repository-writes.sh", ]; const DENY_DANGEROUS_POLICY_FILES = [ ".goat-flow/hooks/deny-dangerous/patterns-shell.sh", ".goat-flow/hooks/deny-dangerous/patterns-paths.sh", ".goat-flow/hooks/deny-dangerous/patterns-writes.sh", ".goat-flow/hooks/deny-dangerous/deny-dangerous-self-test.sh", ]; /** Detect shell lines that intentionally mask validation failures with `|| true`. */ function lineSwallowsValidationFailure(line) { if (line.includes("|| true") === false) return false; if (line.trimStart().startsWith("#")) return false; if (/\bcommand\s+-v\b/.test(line)) return false; return POST_TURN_VALIDATION_COMMAND_PATTERN.test(line); } /** Detect whether a post-turn hook runs real lint, typecheck, or format-check commands. */ function hasPostTurnValidationCommands(hookContent) { return hookContent .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0 && line.startsWith("#") === false) .some((line) => POST_TURN_VALIDATION_COMMAND_PATTERN.test(line)); } /** Analyze a hook script for post-turn validation characteristics. */ function analyzePostTurnScript(hookContent) { /** Non-empty, non-comment lines from the hook script */ const lines = hookContent .trim() .split("\n") .filter((l) => l.trim() && l.trim().startsWith("#") === false); /** Last meaningful line of the hook script */ const lastLine = lines[lines.length - 1]; return { exitsZero: lastLine !== undefined && lastLine.trim() === "exit 0", hasValidation: hasPostTurnValidationCommands(hookContent), swallowsFailures: lines.some(lineSwallowsValidationFailure), }; } /** Check deny hook script content for quality indicators. */ function analyzeDenyScript(denyContent) { return { hasBlocks: /exit\s+2|block|BLOCK/i.test(denyContent) && denyContent.split("\n").length > 5, usesJq: /\bjq\b/.test(denyContent) && !/grep\s+-[a-zA-Z]*P/.test(denyContent .split("\n") .filter((l) => !l.trimStart().startsWith("#")) .join("\n")), handlesChaining: /&&|\|\||;/.test(denyContent) && /split|segment|chain/i.test(denyContent), blocksRmRf: /rm\s*.*-.*r.*f|rm\s*-rf/i.test(denyContent), blocksGitPush: /git\s+push/i.test(denyContent), blocksChmod: /chmod.*777/.test(denyContent), blocksPipeToShell: /(curl|wget)[^|]*\|\s*(ba)?sh/i.test(denyContent) || /pipe-to-shell/i.test(denyContent), blocksCloudDestructive: /docker\s+push|terraform\s+(destroy|apply.*-auto-approve)|aws\s+(s3\s+rm|ec2\s+terminate)/i.test(denyContent), }; } /** Apply settings-based Bash deny pattern overrides to hook facts. */ function applySettingsDenyOverrides(denyStr, hook) { // Settings deny counts as a deny mechanism existing if (hook.denyExists === false && denyStr.includes("Bash(")) { hook.denyExists = true; // settings.json deny is mechanical blocking hook.denyHasBlocks = true; // Config-based deny - jq/chaining checks are not applicable hook.denyIsConfigBased = true; } // Mirror the shell-hook safety checks against settings-based Bash deny rules. if (/Bash\(.*rm -rf|Bash\(.*rm -fr/i.test(denyStr)) hook.denyBlocksRmRf = true; if (/Bash\(.*git push/i.test(denyStr)) hook.denyBlocksGitPush = true; if (/Bash\(.*chmod 777/i.test(denyStr)) hook.denyBlocksChmod = true; if (/Bash\(.*(curl|wget).*(\|\s*(ba)?sh|\|\s*sh)/i.test(denyStr) || /Bash\(.*pipe-to-shell/i.test(denyStr)) { hook.denyBlocksPipeToShell = true; } if (/Bash\(.*(docker push|terraform destroy|terraform apply|aws s3 rm|aws ec2 terminate)/i.test(denyStr)) hook.denyBlocksCloudDestructive = true; } /** Enrich deny hook facts from settings.json Bash deny patterns. */ function enrichDenyFromSettings(settingsParsed, hasDenyPatterns, hook) { if (!hasDenyPatterns || !settingsParsed) return; /** Permissions object from the parsed settings */ const perms = settingsParsed.permissions; /** Raw deny array from permissions */ const rawDeny = perms?.deny; if (!Array.isArray(rawDeny)) return; /** All deny patterns concatenated for pattern matching */ const denyStr = rawDeny.join(" "); applySettingsDenyOverrides(denyStr, hook); } /** Resolve the deny hook script path for the current agent, if it has one. */ function resolveDenyHookPath(fs, agent) { const singleDispatcher = agent.hooksDir ? `${agent.hooksDir}/deny-dangerous.sh` : null; if (singleDispatcher && fs.exists(singleDispatcher)) return singleDispatcher; const explicitHook = agent.denyHookFile && fs.exists(agent.denyHookFile) ? agent.denyHookFile : null; if (explicitHook) return explicitHook; const splitGuardrail = agent.hooksDir ? `${agent.hooksDir}/guard-repository-writes.sh` : null; if (splitGuardrail && fs.exists(splitGuardrail)) return splitGuardrail; return resolveDenyMechanismPath(agent); } /** Resolve the primary deny mechanism path for agents that may use settings, scripts, or both. */ function resolveDenyMechanismPath(agent) { if (agent.denyMechanism?.type === "deny-script") { return agent.denyMechanism.path; } if (agent.denyMechanism?.type === "both") { return agent.denyMechanism.scriptPath; } return null; } function siblingGuardrailPaths(fs, denyHookPath) { if (!denyHookPath) return []; if (denyHookPath.endsWith("/deny-dangerous.sh")) { return DENY_DANGEROUS_POLICY_FILES.every((path) => fs.exists(path)) ? DENY_DANGEROUS_POLICY_FILES : []; } const slash = denyHookPath.lastIndexOf("/"); if (slash === -1) return []; const dir = denyHookPath.slice(0, slash); const paths = LEGACY_GUARDRAIL_HOOK_FILES.map((file) => `${dir}/${file}`); return paths.every((path) => fs.exists(path)) ? paths : []; } /** Build the empty deny-hook fact object used when no deny hook is available. */ function createEmptyDenyFacts(denyExists) { return { denyExists, denyHasBlocks: false, denyIsConfigBased: false, denyUsesJq: false, denyHandlesChaining: false, denyBlocksRmRf: false, denyBlocksGitPush: false, denyBlocksChmod: false, denyBlocksPipeToShell: false, denyBlocksCloudDestructive: false, }; } /** Analyze the deny hook script on disk for the blocking behaviors we care about. */ function analyzeDenyHookPath(fs, denyHookPath) { const denyExists = denyHookPath !== null && fs.exists(denyHookPath); const hook = createEmptyDenyFacts(denyExists); if (!denyExists || !denyHookPath) { return hook; } const denyContent = fs.readFile(denyHookPath); if (!denyContent) { return hook; } const guardrailContents = siblingGuardrailPaths(fs, denyHookPath) .map((path) => fs.readFile(path)) .filter((content) => typeof content === "string"); const analysis = analyzeDenyScript(guardrailContents.length > 0 ? guardrailContents.join("\n") : denyContent); return { ...hook, denyHasBlocks: analysis.hasBlocks, denyUsesJq: analysis.usesJq, denyHandlesChaining: analysis.handlesChaining, denyBlocksRmRf: analysis.blocksRmRf, denyBlocksGitPush: analysis.blocksGitPush, denyBlocksChmod: analysis.blocksChmod, denyBlocksPipeToShell: analysis.blocksPipeToShell, denyBlocksCloudDestructive: analysis.blocksCloudDestructive, }; } /** Detect the executable secret-blocking rule, not just probe labels. */ function denyHookHasActiveSecretRule(content) { const hasSecretFunction = /\bis_secret_path_touch\s*\(\)/.test(content); const hasSecretFlag = /\btouches_secret\b/.test(content); const hasSecretBlock = /block\s+["']Secret-file access/.test(content); return [hasSecretFunction, hasSecretFlag].every(Boolean) || hasSecretBlock; } /** Detect relative/home root normalization for secret path checks. */ function denyHookHasNormalizedSecretRoots(content) { const hasRootMatcher = content.includes("((\\./|\\.\\./|~/)*)"); const hasSelfTestRoots = [ "cat ./.env", "cat ../.env", "cat ~/.ssh/id_rsa", ].every((marker) => content.includes(marker)); return hasRootMatcher || hasSelfTestRoots; } /** Detect the direct literal secret-path families the Bash hook should block. */ function denyHookHasSecretFamilyMarkers(content) { const hasKeys = content.includes("\\.(pem|key|pfx)") || content.includes("\\.(pem|key|pfx|p12)") || content.includes("\\.\\(pem\\|key\\|pfx\\)"); return [ /\\\.env/.test(content), /\\\.env\\\.example/.test(content) || /\.env\.example/.test(content), /\\\.ssh\//.test(content) || /\/\\\.ssh\//.test(content), /\\\.aws\//.test(content) || /\/\\\.aws\//.test(content), /secrets\//.test(content), /credentials/.test(content) || /\\\.npmrc|\\\.pypirc/.test(content), hasKeys, ].every(Boolean); } /** Detect whether the Bash deny hook blocks direct literal secret-bearing paths * (.env, SSH/AWS paths, credentials, and key material). Required because * file-read deny rules do not apply to Bash. */ function detectBashDenyCoversSecrets(fs, denyHookPath) { if (!denyHookPath || !fs.exists(denyHookPath)) return false; const secretSibling = siblingGuardrailPaths(fs, denyHookPath).find((path) => path.endsWith("/patterns-paths.sh") || path.endsWith("/guard-secret-paths.sh")); const content = fs.readFile(secretSibling ?? denyHookPath); if (!content) return false; return (denyHookHasActiveSecretRule(content) && denyHookHasNormalizedSecretRoots(content) && denyHookHasSecretFamilyMarkers(content)); } /** Detect hardcoded absolute paths inside shell hook lines. */ function lineHasAbsolutePath(line) { return (!line.trimStart().startsWith("#") && !/\$\(git rev-parse/.test(line) && /\/(home|Users|tmp|var|opt)\/\w+\//.test(line)); } /** List hook scripts that contain hardcoded absolute paths. */ function findAbsolutePathHooks(fs, hooksDir) { if (!hooksDir || !fs.exists(hooksDir)) return []; const absolutePathHooks = []; for (const hookFile of fs.listDir(hooksDir)) { if (!hookFile.endsWith(".sh")) continue; const hookContent = fs.readFile(`${hooksDir}/${hookFile}`); if (!hookContent) continue; if (hookContent.split("\n").some(lineHasAbsolutePath)) { absolutePathHooks.push(hookFile); } } return absolutePathHooks; } /** * Extract all hook-related facts: deny hooks, post-turn, and compaction registration. * * @param fs - project filesystem adapter used to inspect installed hook files * @param agent - agent profile whose hook locations and event model are being read * @param settingsParsed - parsed agent settings object, or null/unknown when parsing failed * @param hasDenyPatterns - whether settings-level deny patterns cover dangerous operations * @param settingsValid - whether the agent settings file parsed cleanly * @returns hook facts excluding secret-pattern coverage, which settings extraction owns */ export function extractHookFacts(fs, agent, settingsParsed, hasDenyPatterns, settingsValid) { const hookConfig = readHookConfig(fs, agent, settingsParsed, settingsValid); const registration = buildHookRegistration(agent, hookConfig.parsed); const denyHookPath = resolveDenyHookPath(fs, agent); const hook = analyzeDenyHookPath(fs, denyHookPath); const absolutePathHooks = findAbsolutePathHooks(fs, agent.hooksDir); const denyRegistration = buildDenyRegistration(agent, hookConfig.parsed); const bashDenyCoversSecrets = detectBashDenyCoversSecrets(fs, denyHookPath); // Second: also check settings.json Bash deny patterns enrichDenyFromSettings(settingsParsed, hasDenyPatterns, hook); const postTurn = extractPostTurnFacts(fs, agent, registration); return { ...hook, ...denyRegistration, ...postTurn, absolutePathHooks, bashDenyCoversSecrets, }; } /** Analyze a hook script file for exit-zero and validation behavior. */ function analyzeHookScriptAtPath(fs, scriptPath) { const hookContent = fs.readFile(scriptPath); if (!hookContent) { return { postTurnExitsZero: false, postTurnHasValidation: false, postTurnSwallowsFailures: false, }; } const analysis = analyzePostTurnScript(hookContent); return { postTurnExitsZero: analysis.exitsZero, postTurnHasValidation: analysis.hasValidation, postTurnSwallowsFailures: analysis.swallowsFailures, }; } /** Extract post-turn facts from Codex hook registration. */ function extractCodexPostTurnFacts(fs, registration) { const postTurnRegisteredPath = registration.postTurnRegisteredPath; const postTurnExists = registration.postTurnRegistered && postTurnRegisteredPath !== null && fs.exists(postTurnRegisteredPath); const postTurnExecutable = postTurnExists ? fs.isExecutable(postTurnRegisteredPath) : false; return { postTurnRegistered: registration.postTurnRegistered, postTurnRegisteredPath, postTurnExists, postTurnExecutable, ...(postTurnExists && postTurnRegisteredPath ? analyzeHookScriptAtPath(fs, postTurnRegisteredPath) : { postTurnExitsZero: false, postTurnHasValidation: false, postTurnSwallowsFailures: false, }), }; } /** Extract post-turn facts from shell hook directories. */ function extractDirectoryPostTurnFacts(fs, registration) { const postTurnRegisteredPath = registration.postTurnRegisteredPath; const postTurnExists = registration.postTurnRegistered && postTurnRegisteredPath !== null && fs.exists(postTurnRegisteredPath); const postTurnExecutable = postTurnExists ? fs.isExecutable(postTurnRegisteredPath) : false; return { postTurnRegistered: registration.postTurnRegistered, postTurnRegisteredPath, postTurnExists, postTurnExecutable, ...(postTurnExists && postTurnRegisteredPath ? analyzeHookScriptAtPath(fs, postTurnRegisteredPath) : { postTurnExitsZero: false, postTurnHasValidation: false, postTurnSwallowsFailures: false, }), }; } /** Extract post-turn and post-tool hook facts. */ function extractPostTurnFacts(fs, agent, registration) { if (agent.id === "codex") { return extractCodexPostTurnFacts(fs, registration); } if (agent.hooksDir) { return extractDirectoryPostTurnFacts(fs, registration); } return { postTurnRegistered: false, postTurnRegisteredPath: null, postTurnExists: false, postTurnExecutable: false, postTurnExitsZero: false, postTurnHasValidation: false, postTurnSwallowsFailures: false, }; } //# sourceMappingURL=hooks.js.map