voyage/tests/hooks/approval-ask.test.mjs
Kjell Tore Guttormsen 2ee8137e7c
feat(hooks): wave-1 guards -- approval ask, plan gate, test lock, post-edit check
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>
2026-10-06 20:09:32 +02:00

126 lines
6.8 KiB
JavaScript

// tests/hooks/approval-ask.test.mjs
// P12 — approval as a guard. The Bash guard answers `permissionDecision: "ask"`
// for merge into main, push, tag/release and destructive git, so a person sees
// the call before it runs — also in auto mode (code.claude.com/docs/en/hooks
// § PreToolUse decision control: «A hook's "ask" also forces a permission
// prompt in auto mode»). Tested both ways per event: every "must ask" has a
// "must pass" neighbour, so a guard that asks about everything fails here too.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { join } from 'node:path';
import { runHook } from '../helpers/hook-helper.mjs';
import { ROOT, scriptsFor, makeRepo, gitIn, parseJsonOut } from './guard-helpers.mjs';
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
const { classifyGitApproval } = await import(join(ROOT, 'hooks', 'lib', 'git-approval.mjs')).catch(() => ({}));
const MAIN_REPO = makeRepo('main');
const FEATURE_REPO = makeRepo('main');
gitIn(FEATURE_REPO, 'checkout', '-q', '-b', 'feat');
async function decide(command, cwd = FEATURE_REPO) {
const r = await runHook(PRE_BASH, { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } });
let out = null;
try { out = parseJsonOut(r.stdout); } catch { out = null; }
return { ...r, out, decision: out?.hookSpecificOutput?.permissionDecision ?? null };
}
const MUST_ASK = [
// [command, cwd, why]
['git push origin main', FEATURE_REPO, 'push to main'],
['git push', FEATURE_REPO, 'push (any branch)'],
['git push origin feat', FEATURE_REPO, 'push of a feature branch'],
['git push --force-with-lease origin feat', FEATURE_REPO, 'force push'],
['git push --tags', FEATURE_REPO, 'push of tags'],
['git -C /tmp/x push origin main', FEATURE_REPO, 'git with -C before the subcommand'],
['FOO=1 git push origin main', FEATURE_REPO, 'env-prefixed push'],
['npm test && git push origin main', FEATURE_REPO, 'push after a test'],
['sh -c "git push origin main"', FEATURE_REPO, 'push inside a shell wrapper'],
['git merge --no-ff worktree-x', MAIN_REPO, 'merge while on main'],
['git merge --ff-only feat', MAIN_REPO, 'fast-forward merge while on main'],
['git checkout main && git merge feat', FEATURE_REPO, 'switch to main, then merge'],
['git switch master; git merge feat', FEATURE_REPO, 'switch to master, then merge'],
['git tag v1.2.3', FEATURE_REPO, 'create a tag'],
['git tag -a v1 -m "release"', FEATURE_REPO, 'annotated tag'],
['git tag -d v1', FEATURE_REPO, 'delete a tag'],
['npm publish', FEATURE_REPO, 'publish a package'],
['git reset --hard HEAD~1', FEATURE_REPO, 'hard reset'],
['git clean -fd', FEATURE_REPO, 'clean with force'],
['git clean -xdf', FEATURE_REPO, 'clean with combined flags'],
['git branch -D feat', FEATURE_REPO, 'force-delete a branch'],
['git branch --delete --force feat', FEATURE_REPO, 'force-delete, long flags'],
['git stash drop', FEATURE_REPO, 'drop a stash'],
['git stash clear', FEATURE_REPO, 'clear all stashes'],
['git checkout -- src/a.js', FEATURE_REPO, 'discard changes to a file'],
['git checkout .', FEATURE_REPO, 'discard all changes'],
['git restore src/a.js', FEATURE_REPO, 'restore the working tree'],
['git filter-branch --tree-filter x HEAD', FEATURE_REPO, 'history rewrite'],
];
const MUST_PASS = [
['git status', FEATURE_REPO, 'read-only'],
['git log --oneline -5', FEATURE_REPO, 'read-only'],
['git merge-base --is-ancestor main HEAD', MAIN_REPO, 'merge-base is not a merge'],
['git merge feat', FEATURE_REPO, 'merge into a feature branch'],
['git merge --abort', MAIN_REPO, 'aborting a merge'],
['git tag', FEATURE_REPO, 'list tags'],
['git tag -l "v*"', FEATURE_REPO, 'list tags with a pattern'],
['git tag --list', FEATURE_REPO, 'list tags, long flag'],
['echo "git push origin main"', FEATURE_REPO, 'quoted data, not a command'],
['git commit -m "never git push to main"', FEATURE_REPO, 'commit message naming push'],
['git restore --staged src/a.js', FEATURE_REPO, 'unstage only'],
['git branch -d feat', FEATURE_REPO, 'safe branch delete'],
['git stash list', FEATURE_REPO, 'list stashes'],
['git checkout -b new-branch', FEATURE_REPO, 'create a branch'],
['git checkout main', FEATURE_REPO, 'switch branch'],
['git fetch origin', FEATURE_REPO, 'fetch'],
['npm test', FEATURE_REPO, 'tests'],
['git worktree remove /tmp/wt --force', FEATURE_REPO, 'worktree cleanup (Hard Rule 12)'],
['git clean -n', FEATURE_REPO, 'clean dry run'],
];
for (const [cmd, cwd, why] of MUST_ASK) {
test(`P12 must ask: ${cmd} (${why})`, async () => {
const r = await decide(cmd, cwd);
assert.equal(r.code, 0, `ask is a JSON answer on exit 0, not a block; stderr: ${r.stderr}`);
assert.equal(r.decision, 'ask', `expected permissionDecision "ask" for \`${cmd}\`; stdout: ${r.stdout}`);
assert.equal(r.out.hookSpecificOutput.hookEventName, 'PreToolUse');
const reason = r.out.hookSpecificOutput.permissionDecisionReason;
assert.match(reason, /Voyage/, 'the prompt names Voyage as the asker');
assert.match(reason, /Way forward:/, 'the prompt explains the way forward');
});
}
for (const [cmd, cwd, why] of MUST_PASS) {
test(`P12 must pass: ${cmd} (${why})`, async () => {
const r = await decide(cmd, cwd);
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
assert.equal(r.decision, null, `no ask expected for \`${cmd}\`; stdout: ${r.stdout}`);
});
}
test('P12: a BLOCK rule still wins over ask (exit 2, no JSON)', async () => {
const r = await decide('git push origin main; rm -rf /');
assert.equal(r.code, 2);
});
test('P12: the Bash guard that asks is the one hooks.json runs for Bash', () => {
const scripts = scriptsFor('PreToolUse', 'Bash');
assert.ok(scripts.some((s) => s.endsWith('pre-bash-executor.mjs')), JSON.stringify(scripts));
});
test('P12: approval kinds follow the operator decision (06.10): merge/push to main = merge, tag/publish = release, the rest unlogged', () => {
assert.equal(typeof classifyGitApproval, 'function', 'hooks/lib/git-approval.mjs must export classifyGitApproval');
const kinds = (cmd, branch) => classifyGitApproval(cmd, { branch }).map((m) => m.kind);
assert.deepEqual(kinds('git merge feat', 'main'), ['merge']);
assert.deepEqual(kinds('git push origin main', 'feat'), ['merge']);
assert.deepEqual(kinds('git push origin HEAD:refs/heads/main', 'feat'), ['merge']);
assert.deepEqual(kinds('git push', 'main'), ['merge'], 'a bare push from main pushes main');
assert.deepEqual(kinds('git push origin feat', 'feat'), [null]);
assert.deepEqual(kinds('git tag v1', 'feat'), ['release']);
assert.deepEqual(kinds('git push --tags', 'feat'), ['release']);
assert.deepEqual(kinds('npm publish', 'feat'), ['release']);
assert.deepEqual(kinds('git reset --hard', 'feat'), [null]);
assert.deepEqual(kinds('git status', 'main'), []);
});