// 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)}`); } });