export type GitShellPolicyInput = { command: string; defaultBranch: string | null | undefined; gitPushApprovedThisRun: boolean; }; export type GitShellPolicyDecision = | { action: "allow" } | { action: "block"; reason: string } | { action: "require_approval"; reason: string }; const GIT_PUSH_RE = /\bgit\b[^\n;&|]*\bpush\b/i; const GIT_FORCE_PUSH_RE = /(?:--force-with-lease|--force(?:\s|$)|(?:^|\s)-f(?:\s|$))/i; const GH_PR_RE = /\bgh\b(?:(?:\s+[^;&|]+)*\s+pr\b|\s+pr\b)(?:\s+create\b|\s|$|[;&|])/i; export function evaluateGitShellPolicy(input: GitShellPolicyInput): GitShellPolicyDecision { const command = input.command.trim(); if (!command) return { action: "allow" }; if (GIT_PUSH_RE.test(command)) { if (GIT_FORCE_PUSH_RE.test(command)) { return { action: "block", reason: "Force-push is not allowed. Push to a new branch without force flags.", }; } const targetBranch = parseGitPushRef(command); const protectedBranch = normalizeBranchName(input.defaultBranch); if (protectedBranch && targetBranch && targetBranch === protectedBranch) { return { action: "block", reason: `Push to the default branch (${protectedBranch}) is not allowed. Create a feature branch and push that instead.`, }; } if (!input.gitPushApprovedThisRun) { return { action: "require_approval", reason: "This run would push commits to a remote. Approve once to allow git push in this run.", }; } } if (GH_PR_RE.test(command) && !input.gitPushApprovedThisRun) { return { action: "require_approval", reason: "Opening a pull request is a consequential git action. Approve once to allow gh pr in this run.", }; } return { action: "allow" }; } function normalizeBranchName(value: string | null | undefined): string | null { const trimmed = value?.trim(); if (!trimmed || trimmed === "HEAD") return null; return trimmed.replace(/^origin\//, ""); } /** Best-effort parse of the ref git push would update on the remote. */ export function parseGitPushRef(command: string): string | null { const tokens = command .replace(/&&/g, ";") .replace(/\|\|/g, ";") .split(/[;&|]/) .flatMap((segment) => segment.trim().split(/\s+/)) .filter(Boolean); const pushIndex = tokens.indexOf("push"); if (pushIndex < 0 || !tokens.slice(0, pushIndex).includes("git")) return null; const args = tokens.slice(pushIndex + 1).filter((token) => !token.startsWith("-")); if (args.length === 0) return null; const last = args.at(-1); if (!last) return null; if (last.includes(":")) { const [, dest] = last.split(":"); return normalizeBranchName(dest) ?? null; } if (args.length >= 2) return normalizeBranchName(last); return normalizeBranchName(last); } export function gitNetworkArgv(argv: string[]): boolean { if (argv.length === 0 || argv[0] !== "git") return false; const sub = argv[1]; return ( sub === "push" || sub === "fetch" || sub === "pull" || sub === "clone" || sub === "ls-remote" ); } export function ghBrokerArgv(argv: string[]): boolean { if (argv.length === 0 || argv[0] !== "gh") return false; const joined = argv.join(" "); return GH_PR_RE.test(joined); }