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

89 lines
3.9 KiB
JavaScript

#!/usr/bin/env node
// Hook: post-approval-log.mjs
// Event: PostToolUse (Bash, ExitPlanMode)
// Purpose: write an approval a person gave through Claude Code's prompt into
// the approval log (contract K2, lib/util/approval-log.mjs).
// - Bash: the call ran, so the ask from pre-bash-executor.mjs was approved.
// What is logged is the record that hook wrote of what the person was
// asked (hooks/lib/approval-record.mjs); no record, nothing logged.
// Merge/push to main is logged as `merge`, tag/publish as `release`, each
// with `--expires` (APPROVAL_TTL_MS). Push of other branches and
// destructive git are not logged (operator decision 06.10). In
// bypassPermissions mode nothing is logged: no person was asked.
// sha256 binds the approval to the command and the commit it left HEAD on.
// - ExitPlanMode: the person approved a plan. A Voyage plan (it carries
// `plan_version`) is logged as `plan`, bound to the sha256 of the approved
// text (`tool_response.plan`, hooks reference section ExitPlanMode).
// The guard never blocks (PostToolUse cannot), and it is never silent about a
// lost approval: when K2 is missing, refuses (1) or cannot run (2), Claude is
// told "not logged".
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { APPROVAL_TTL_MS } from '../lib/git-approval.mjs';
import { takeApprovalRecord } from '../lib/approval-record.mjs';
import { runContract, isVoyagePlan } from '../lib/contract-cli.mjs';
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
function say(text) {
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text } }));
}
function headOf(cwd) {
const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', timeout: 10_000 });
return r.status === 0 ? r.stdout.trim() : '';
}
function append(kind, subject, hash, cwd, expires) {
const args = ['append', '--kind', kind, '--subject', subject, '--sha256', hash];
if (expires) args.push('--expires', expires);
const r = runContract('K2', args, { cwd });
if (!r.ran) return `${kind} approval not logged: ${r.why}`;
if (r.code === 0) return `${kind} approval logged${expires ? ` (expires ${expires})` : ''}`;
const why = (r.stderr || r.stdout).trim().split('\n')[0] || `exit ${r.code}`;
return `${kind} approval not logged: the approval log ${r.code === 1 ? 'refused it' : 'could not run'} (${why})`;
}
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
process.exit(0);
}
const cwd = input?.cwd || process.cwd();
if (input?.tool_name === 'ExitPlanMode') {
const plan = input?.tool_response?.plan ?? input?.tool_input?.plan;
if (!isVoyagePlan(plan)) process.exit(0);
const subject = input?.tool_response?.filePath || input?.tool_input?.planFilePath || '(plan without a file path)';
say(`[voyage] ${append('plan', subject, sha256(plan), cwd)}.`);
process.exit(0);
}
if (input?.tool_name === 'Bash') {
// Log what the person was asked (pre-bash-executor.mjs wrote it), nothing else.
const rec = takeApprovalRecord(input?.session_id, input?.tool_use_id);
const matches = (rec?.matches || []).filter((m) => m.kind);
if (matches.length === 0) process.exit(0);
if (rec.permission_mode === 'bypassPermissions') {
say('[voyage] approval not logged: the session runs in bypassPermissions mode, so no person was asked.');
process.exit(0);
}
const expires = new Date(Date.now() + APPROVAL_TTL_MS).toISOString();
const seen = new Set();
const notes = [];
for (const m of matches) {
const key = `${m.kind}\n${m.subject}`;
if (seen.has(key)) continue;
seen.add(key);
const subject = m.subject || cwd;
const hash = sha256(`${rec.command}\n${headOf(subject)}`);
notes.push(append(m.kind, subject, hash, cwd, expires));
}
say(`[voyage] ${notes.join('; ')}.`);
}
process.exit(0);