voyage/tests/hooks/approval-log.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

135 lines
5.8 KiB
JavaScript

// tests/hooks/approval-log.test.mjs
// P12 + P3 — an approval a person gave through Claude Code's prompt is written
// to the approval log (contract K2, lib/util/approval-log.mjs, owned by wave-1
// order 2). The log CLI is a STUB here until order 2 is merged: the stub
// records its argv. When the CLI is missing, the guard says "not logged" —
// never green, never silent.
//
// Operator decision 06.10: merge/push to main is logged as `merge`, tag and
// publish as `release`; push of other branches and destructive git are asked
// about but not logged. P12 approvals carry an expiry (`--expires`).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
import { scriptsFor, pluginCopy, makeRepo, gitIn, tmp, readStubLog, parseJsonOut } from './guard-helpers.mjs';
const K2 = 'lib/util/approval-log.mjs';
const STUBBED = pluginCopy({ [K2]: 'stub' });
const MISSING = pluginCopy({ [K2]: 'missing' });
const hookIn = (root) => join(root, 'hooks', 'scripts', 'post-approval-log.mjs');
const REPO = makeRepo('main');
gitIn(REPO, 'checkout', '-q', '-b', 'feat');
const sha = (s) => createHash('sha256').update(s).digest('hex');
let callNo = 0;
// A Bash call goes through the same two hooks as in a session: the guard asks
// (and records what it asked), the call runs, then the approval is logged.
async function post(root, input, env = {}) {
const logFile = join(tmp(), 'stub.jsonl');
const ids = { session_id: 'sess-log', tool_use_id: `toolu_log_${(callNo += 1)}` };
if (input.tool_name === 'Bash') {
await runHookWithEnv(join(root, 'hooks', 'scripts', 'pre-bash-executor.mjs'),
{ hook_event_name: 'PreToolUse', cwd: REPO, ...ids, tool_name: 'Bash', tool_input: input.tool_input }, env);
}
const r = await runHookWithEnv(hookIn(root), { hook_event_name: 'PostToolUse', cwd: REPO, ...ids, ...input }, { STUB_LOG: logFile, ...env });
return { ...r, calls: readStubLog(logFile), out: parseJsonOut(r.stdout) };
}
const bash = (command) => ({ tool_name: 'Bash', tool_input: { command }, tool_response: { stdout: '', stderr: '' } });
const VOYAGE_PLAN = '---\nplan_version: 1.7\n---\n\n# Plan\n\n### Step 1: x\n';
const OTHER_PLAN = '# Refactor auth\n\n1. Extract the helper\n';
const exitPlan = (plan, filePath = '/tmp/plans/p.md') => ({
tool_name: 'ExitPlanMode',
tool_input: { plan, planFilePath: filePath },
tool_response: { plan, filePath },
});
function arg(call, flag) {
const i = call.argv.indexOf(flag);
return i === -1 ? undefined : call.argv[i + 1];
}
test('K2: push to main is logged as merge, with subject, sha256 and an expiry', async () => {
const r = await post(STUBBED, bash('git push origin main'));
assert.equal(r.code, 0, r.stderr);
assert.equal(r.calls.length, 1, `one append expected; got ${JSON.stringify(r.calls)}`);
const c = r.calls[0];
assert.equal(c.argv[0], 'append');
assert.equal(arg(c, '--kind'), 'merge');
assert.ok(arg(c, '--subject'), 'subject (a path) is given');
assert.match(arg(c, '--sha256'), /^[0-9a-f]{64}$/);
const exp = Date.parse(arg(c, '--expires'));
assert.ok(Number.isFinite(exp) && exp > Date.now(), 'expires is a future ISO time');
assert.match(r.out.hookSpecificOutput.additionalContext, /approval logged/i);
});
test('K2: a tag is logged as release', async () => {
const r = await post(STUBBED, bash('git tag v9.9.9'));
assert.equal(r.calls.length, 1);
assert.equal(arg(r.calls[0], '--kind'), 'release');
});
test('K2: push of a feature branch is asked about but NOT logged', async () => {
const r = await post(STUBBED, bash('git push origin feat'));
assert.equal(r.code, 0);
assert.equal(r.calls.length, 0);
});
test('K2: destructive git is asked about but NOT logged', async () => {
const r = await post(STUBBED, bash('git reset --hard HEAD~1'));
assert.equal(r.code, 0, r.stderr);
assert.equal(r.calls.length, 0);
});
test('K2: an ordinary command logs nothing and says nothing', async () => {
const r = await post(STUBBED, bash('npm test'));
assert.equal(r.code, 0, r.stderr);
assert.equal(r.calls.length, 0);
assert.equal(r.out, null);
});
test('K2 missing: the approval is reported "not logged", never green', async () => {
const r = await post(MISSING, bash('git push origin main'));
assert.equal(r.code, 0);
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
});
test('K2 refuses (exit 1): reported "not logged"', async () => {
const r = await post(STUBBED, bash('git push origin main'), { STUB_EXIT: '1' });
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
});
test('K2 could not run (exit 2): reported "not logged"', async () => {
const r = await post(STUBBED, bash('git push origin main'), { STUB_EXIT: '2' });
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
});
test('P3: an approved Voyage plan is logged as plan, bound to the sha256 of the approved text', async () => {
const r = await post(STUBBED, exitPlan(VOYAGE_PLAN, '/tmp/plans/voyage-plan.md'));
assert.equal(r.calls.length, 1, JSON.stringify(r.calls));
const c = r.calls[0];
assert.equal(arg(c, '--kind'), 'plan');
assert.equal(arg(c, '--subject'), '/tmp/plans/voyage-plan.md');
assert.equal(arg(c, '--sha256'), sha(VOYAGE_PLAN));
});
test('P3: an approved plan without plan_version is not Voyage\'s and is not logged', async () => {
const r = await post(STUBBED, exitPlan(OTHER_PLAN));
assert.equal(r.code, 0, r.stderr);
assert.equal(r.calls.length, 0);
assert.equal(r.out, null);
});
test('wiring: hooks.json runs post-approval-log on PostToolUse for Bash and ExitPlanMode', () => {
for (const tool of ['Bash', 'ExitPlanMode']) {
const s = scriptsFor('PostToolUse', tool);
assert.ok(s.some((p) => p.endsWith('post-approval-log.mjs')), `${tool}: ${JSON.stringify(s)}`);
}
});