#!/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);