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>
89 lines
3.9 KiB
JavaScript
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);
|