@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
JavaScript
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