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>
This commit is contained in:
parent
2c89a6b580
commit
2ee8137e7c
31 changed files with 3278 additions and 107 deletions
89
hooks/scripts/post-approval-log.mjs
Normal file
89
hooks/scripts/post-approval-log.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#!/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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue