Wave-1 order 4 (P7, P12, the P9 test lock, the P3 plan-approval guard). All command hooks; none runs a model. - P12: pre-bash-executor answers permissionDecision "ask" before merge into main, push, tag/publish and destructive git (holds in auto mode). post-approval-log writes exactly what the person was asked to the K2 approval log: merge/push to main as `merge`, tag/publish as `release`, with a one-hour expiry; nothing when no person was asked. - P3: pre-plan-gate runs the K3 plan gate on a Voyage plan leaving plan mode and sends a refused plan back; "not measured" asks, never passes. - P9: lib/util/test-lock.mjs (K8). pre-write-executor refuses edits to locked test files; stop-test-lock refuses "done" for the locking session (stop_hook_active guarded). - P7: post-edit-check runs the target repo's own formatter/linter per edit, only during a Voyage plan run or after opt-in (.claude/voyage-guards.json); lib/verification/step-check.mjs (K9) fails the step on findings; no tool reads "not measured", never green. - STORM cap matcher also names `Agent` (the tool's name since 2.1.63). - Mod precondition stated in README, SECURITY.md and every PreToolUse stop guard: Voyage's guards are deterministic only when no installed mod handles `tool.check`. K2, K3 and K5 are stubbed in tests until orders 2 and 3 are merged. Two independent review rounds; every finding has a regression test. Suite 1600 (1598 pass / 0 fail / 2 skipped). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
382 lines
16 KiB
JavaScript
382 lines
16 KiB
JavaScript
// 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 <dir>`, `git -C <dir>` and
|
|
// `git checkout|switch <branch>` 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*(?:>>?|<<?)\S/.test(t)) continue;
|
|
out.push(t);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function segments(view) {
|
|
return view
|
|
// `2>&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] <sub> <args...>`, 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}`
|
|
);
|
|
}
|