// hooks/lib/git-approval.mjs // P12 -- which shell commands need a person's approval before they run, and // which of those approvals go into the approval log (contract K2). // // The four classes (merge into main, push, tag/release, destructive git) are // Superpowers' (MIT, `8ca22db`); "a block names the way forward" is // agent-skills' (MIT, `9d0c60d`); guard + log is ai-native-sdlc's (MIT, // `11fb8d7`). Ideas only; no code is copied. // // Log kinds (operator decision 06.10): merge into main and push to main are // logged as `merge`; a tag, a pushed tag and a package publish as `release`. // A push of another branch and destructive git are asked about but not logged // (kind null): K2 has no kind for them, and the contract stays as it is. // // Read on the command-position view (bash-view.mjs), so a quoted commit // message or an `echo` that names `git push` is data, not a command. The // command is followed segment by segment: `cd `, `git -C ` and // `git checkout|switch ` change which repo and branch the next git // call acts on. Not seen: git aliases (`git p`), commands assembled in // variables, and `git worktree remove --force` (routine executor cleanup, // Hard Rule 12). import { existsSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, resolve, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; import { viewOf } from './bash-view.mjs'; // How long a logged P12 approval stays valid (K2 `--expires`). export const APPROVAL_TTL_MS = 60 * 60 * 1000; const MAIN = /^(?:main|master)$/; // Wrapper programs that run the command after them, with the options of each // that take a separate value. const WRAPPERS = { sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-U', '-T'], env: ['-u', '-S', '-C', '--unset', '--chdir', '--split-string'], timeout: ['-s', '-k', '--signal', '--kill-after'], nice: ['-n', '--adjustment'], time: ['-f', '-o', '--format', '--output'], exec: ['-a'], xargs: ['-n', '-I', '-L', '-P', '-d', '-E', '-s', '-a', '--max-args', '--max-procs', '--delimiter', '--arg-file', '--replace'], stdbuf: ['-i', '-o', '-e'], command: [], builtin: [], nohup: [], eval: [], then: [], do: [], else: [], '!': [], if: [], elif: [], while: [], until: [], }; // git options that take a separate value before the subcommand. const GIT_OPTS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env']); const base = (p) => String(p || '').split('/').pop(); // Redirections are not arguments: `git checkout main 2>/dev/null` names one // branch, not a branch and a path. An operator standing alone takes the next // token as its target. function dropRedirects(tokens) { const out = []; for (let i = 0; i < tokens.length; i += 1) { const t = tokens[i]; if (/^\d*(?:>>?|<\|)$/.test(t)) { i += 1; continue; } if (/^\d*(?:>>?|<&1`, `>&2`, `&>file` would otherwise split at their `&`. .replace(/\d*>&\d*-?/g, ' ') .replace(/&>>?/g, ' > ') .split(/\$\(|[\n;|&()`{}]/) .map((s) => s.trim()) .filter(Boolean) .map((s) => dropRedirects(s.split(/\s+/))) .filter((t) => t.length); } // Drop env assignments and wrapper words (with their options); return the // tokens from the real program on. function programTokens(tokens) { let i = 0; while (i < tokens.length) { const t = tokens[i]; if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t) || /^\d+$/.test(t)) { i += 1; continue; } const w = WRAPPERS[base(t)]; if (!w) break; const name = base(t); i += 1; while (i < tokens.length && tokens[i].startsWith('-') && tokens[i] !== '-') { i += w.includes(tokens[i]) ? 2 : 1; } if (name === 'timeout' && i < tokens.length) i += 1; // the duration } return tokens.slice(i); } // For `git [global opts] `, return { sub, args, dir }. function gitCall(tokens) { if (base(tokens[0]) !== 'git') return null; let i = 1; let dir = null; while (i < tokens.length && tokens[i].startsWith('-')) { const t = tokens[i]; if (t === '-C' && tokens[i + 1]) dir = dir ? join(dir, tokens[i + 1]) : tokens[i + 1]; i += GIT_OPTS_WITH_VALUE.has(t) ? 2 : 1; } if (i >= tokens.length) return null; return { sub: tokens[i], args: tokens.slice(i + 1), dir }; } const hasFlag = (args, ...flags) => args.some((a) => flags.includes(a)); // A short-flag cluster such as -fdx contains `ch`. const shortHas = (args, ch) => args.some((a) => /^-[A-Za-z]+$/.test(a) && a.includes(ch)); const positional = (args) => args.filter((a) => !a.startsWith('-')); // Positionals, skipping the values of the options that take one. function positionalSkipping(args, valued) { const out = []; for (let i = 0; i < args.length; i += 1) { if (valued.includes(args[i])) { i += 1; continue; } if (!args[i].startsWith('-')) out.push(args[i]); } return out; } const PUSH_VALUED = ['-o', '--push-option', '--repo', '--receive-pack', '--exec']; const WAY = { merge: 'approve to merge now; deny and Claude stops here. A merge into main is recorded in the approval log.', push: 'approve to push now; deny and Claude stops here. A push to main is recorded in the approval log.', release: 'approve to tag or publish now; deny and Claude stops here. The release is recorded in the approval log.', destructive: 'approve only if losing that work is intended; deny and Claude stops here. Nothing is logged.', }; const destructive = (name) => ({ cls: 'destructive', name, kind: null }); function classifyPush(args, ctx) { if (hasFlag(args, '-n', '--dry-run') || shortHas(args, 'n')) return []; const tags = hasFlag(args, '--tags', '--follow-tags'); const del = hasFlag(args, '--delete', '-d'); const force = hasFlag(args, '--force', '--force-with-lease', '--mirror') || args.some((a) => a.startsWith('--force-with-lease=') || a.startsWith('+')) || shortHas(args, 'f'); const refspecs = positionalSkipping(args, PUSH_VALUED).slice(1); // the first positional is the remote const out = []; let toMain = false; let other = false; let tagPush = tags; for (const r of refspecs) { if (del || r.startsWith(':')) { out.push(destructive('delete a remote branch or tag')); continue; } const dst = (r.includes(':') ? r.split(':').pop() : r).replace(/^\+/, ''); const name = dst.replace(/^refs\/heads\//, ''); if (dst.startsWith('refs/tags/') || (!dst.startsWith('refs/') && tagExists(ctx.cwd, name) && !MAIN.test(name))) tagPush = true; else if (MAIN.test(name) || (name === 'HEAD' && MAIN.test(ctx.branch || ''))) toMain = true; else other = true; } if (refspecs.length === 0 && !tags) { if (MAIN.test(ctx.branch || '')) toMain = true; else other = true; } if (toMain) out.push({ cls: 'push', name: force ? 'force push to main' : 'push to main', kind: 'merge' }); if (other) out.push({ cls: 'push', name: force ? 'force push' : 'push', kind: null }); if (tagPush) out.push({ cls: 'release', name: 'push of tags', kind: 'release' }); return out; } function classifyGit(call, ctx) { const { sub, args } = call; switch (sub) { case 'push': return classifyPush(args, ctx); case 'merge': if (hasFlag(args, '--abort', '--continue', '--quit')) return []; return MAIN.test(ctx.branch || '') ? [{ cls: 'merge', name: 'merge into main', kind: 'merge' }] : []; case 'tag': { if (hasFlag(args, '-l', '--list', '-v', '--verify') || shortHas(args, 'l')) return []; if (hasFlag(args, '-d', '--delete')) return [{ cls: 'release', name: 'delete a tag', kind: null }]; // Options that take a value: their value is not a tag name. const valued = new Set(['-m', '-F', '-u', '--message', '--file', '--local-user', '--sort', '--contains', '--no-contains', '--points-at', '--merged', '--no-merged', '--format', '--cleanup']); const names = []; for (let i = 0; i < args.length; i += 1) { if (valued.has(args[i])) { i += 1; continue; } if (!args[i].startsWith('-')) names.push(args[i]); } return names.length > 0 ? [{ cls: 'release', name: 'create a tag', kind: 'release' }] : []; } case 'reset': return hasFlag(args, '--hard') ? [destructive('hard reset')] : []; case 'clean': if (hasFlag(args, '-n', '--dry-run') || shortHas(args, 'n')) return []; return hasFlag(args, '--force') || shortHas(args, 'f') ? [destructive('clean with force')] : []; case 'branch': { const del = hasFlag(args, '-d', '--delete') || shortHas(args, 'd'); const force = hasFlag(args, '-f', '--force') || shortHas(args, 'f'); if (hasFlag(args, '-D') || shortHas(args, 'D') || (del && force)) return [destructive('force-delete a branch')]; if (hasFlag(args, '-M') || shortHas(args, 'M')) return [destructive('force-rename a branch')]; return []; } case 'stash': return args[0] === 'drop' || args[0] === 'clear' ? [destructive(`stash ${args[0]}`)] : []; case 'checkout': { if (hasFlag(args, '-b', '-B', '--orphan')) return []; const pos = positional(args); const discard = hasFlag(args, '--', '-f', '--force') || pos.includes('.') || pos.length >= 2; return discard ? [destructive('discard working-tree changes')] : []; } case 'switch': return hasFlag(args, '-f', '--force', '--discard-changes') ? [destructive('discard working-tree changes')] : []; case 'restore': { const staged = hasFlag(args, '--staged', '-S'); const worktree = hasFlag(args, '--worktree', '-W'); return !staged || worktree ? [destructive('discard working-tree changes')] : []; } case 'filter-branch': case 'filter-repo': return [destructive('history rewrite')]; case 'update-ref': return hasFlag(args, '-d') ? [destructive('delete a ref')] : []; case 'reflog': return args[0] === 'expire' || args[0] === 'delete' ? [destructive('reflog expiry')] : []; default: return []; } } // The branch a checkout/switch leaves the repo on, or undefined if it does not move. function branchAfter(call) { const { sub, args } = call; if (sub !== 'checkout' && sub !== 'switch') return undefined; if (hasFlag(args, '--detach') || (sub === 'switch' && hasFlag(args, '-d'))) return null; // detached: no branch const create = sub === 'checkout' ? ['-b', '-B', '--orphan'] : ['-c', '-C', '--create', '--force-create', '--orphan']; for (let i = 0; i < args.length; i += 1) if (create.includes(args[i]) && args[i + 1]) return args[i + 1]; if (hasFlag(args, '--')) return undefined; const pos = positional(args); return pos.length === 1 && pos[0] !== '.' ? pos[0] : undefined; } function classifyPublish(tokens) { let t = tokens; if (['npx', 'pnpx', 'bunx'].includes(base(t[0]))) t = t.slice(1); const prog = base(t[0]); if (['npm', 'pnpm', 'yarn', 'bun', 'cargo'].includes(prog)) { const rest = t.slice(1); // The subcommand is the first positional (option values skipped); // `yarn npm publish` is Yarn Berry's form. const pos = positionalSkipping(rest, ['--tag', '--registry', '--access', '--otp', '-w', '--workspace', '--filter', '--cwd', '-C', '--prefix', '--manifest-path', '--package', '-p', '--token']); const sub = prog === 'yarn' && pos[0] === 'npm' ? pos[1] : pos[0]; if (sub === 'publish' && !hasFlag(rest, '--dry-run', '-n')) { return [{ cls: 'release', name: `${prog} publish`, kind: 'release' }]; } } if (prog === 'gh' && t[1] === 'release' && t[2] === 'create') { return [{ cls: 'release', name: 'gh release create', kind: 'release' }]; } return []; } const expandHome = (p) => (p === '~' ? homedir() : p.startsWith('~/') ? join(homedir(), p.slice(2)) : p); /** * Which approvals a command needs. * @param {string} command raw Bash command * @param {{ cwd?: string, branch?: string|null }} ctx where the command runs; `branch` * overrides the branch read from `cwd` for the first segment (tests). * @returns {Array<{cls, name, kind: 'merge'|'release'|null, way, subject}>} * `subject` is the top of the repo the matched call acts on. */ export function classifyGitApproval(command, ctx = {}) { if (!command || typeof command !== 'string') return []; let cwd = resolve(ctx.cwd || process.cwd()); let branch = ctx.branch !== undefined ? ctx.branch : currentBranch(cwd); const out = []; for (const seg of segments(viewOf(command, { keepWords: true }))) { const tokens = programTokens(seg); if (base(tokens[0]) === 'cd') { const to = tokens[1] && tokens[1] !== '-' ? tokens[1] : null; if (to) { cwd = resolve(cwd, expandHome(to)); branch = currentBranch(cwd); } continue; } const call = gitCall(tokens); if (call) { const callCwd = call.dir ? resolve(cwd, expandHome(call.dir)) : cwd; const callBranch = call.dir ? currentBranch(callCwd) : branch; const subject = repoRootOf(callCwd); for (const m of classifyGit(call, { branch: callBranch, cwd: callCwd })) out.push({ ...m, subject }); const next = branchAfter(call); if (next !== undefined && !call.dir) branch = next; continue; } for (const m of classifyPublish(tokens)) out.push({ ...m, subject: repoRootOf(cwd) }); } return out.map((m) => ({ ...m, way: WAY[m.cls] })); } // --------------------------------------------------------------------------- // The repository a command runs in -- read from the filesystem, no git spawn. // --------------------------------------------------------------------------- function findDotGit(start) { let dir = resolve(start); while (!existsSync(dir)) { const up = dirname(dir); if (up === dir) return null; dir = up; } for (;;) { const p = join(dir, '.git'); if (existsSync(p)) return { root: dir, dotGit: p }; const up = dirname(dir); if (up === dir) return null; dir = up; } } function gitDirOf(found) { try { if (statSync(found.dotGit).isDirectory()) return found.dotGit; const m = readFileSync(found.dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m); if (!m) return null; return isAbsolute(m[1]) ? m[1] : resolve(found.root, m[1]); } catch { return null; } } // A worktree's git dir names the shared one in `commondir`; refs live there. function commonDirOf(gitDir) { try { const c = readFileSync(join(gitDir, 'commondir'), 'utf8').trim(); return isAbsolute(c) ? c : resolve(gitDir, c); } catch { return gitDir; } } // The checked-out branch of the repo at `cwd`, or null (detached, no repo). export function currentBranch(cwd) { try { const found = findDotGit(cwd || process.cwd()); if (!found) return null; const gitDir = gitDirOf(found); if (!gitDir) return null; const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim(); const m = head.match(/^ref:\s*refs\/heads\/(.+)$/); return m ? m[1] : null; } catch { return null; } } // Does tag `name` exist in the repo at `cwd` (loose or packed)? export function tagExists(cwd, name) { if (!name || name.includes('..')) return false; try { const found = findDotGit(cwd || process.cwd()); if (!found) return false; const gitDir = gitDirOf(found); if (!gitDir) return false; const common = commonDirOf(gitDir); if (existsSync(join(common, 'refs', 'tags', name))) return true; const packed = join(common, 'packed-refs'); return existsSync(packed) && readFileSync(packed, 'utf8').split('\n').some((l) => l.endsWith(` refs/tags/${name}`)); } catch { return false; } } // The top of the repo at `cwd` (the approval's subject), or cwd itself. export function repoRootOf(cwd) { const found = findDotGit(cwd || process.cwd()); return found ? found.root : resolve(cwd || process.cwd()); } // The prompt text for an ask. Names Voyage, the classes, and the way forward. export function askReason(matches) { const names = [...new Set(matches.map((m) => m.name))].join('; '); const ways = [...new Set(matches.map((m) => m.way))].join(' '); return ( `Voyage asks before this command (P12 approval guard): ${names}. ` + 'It changes something outside this working copy or throws work away, so a person decides. ' + `Way forward: ${ways}` ); }