voyage/lib/util/approval-log.mjs
Kjell Tore Guttormsen 81dc5af680
fix: the trial's mechanical faults -- approval label, import path, stats, annotate path, red kind
voyage-lekerepo end-to-end trial (2026-10-06), report points 8, 10, 12, 15, 16:

- approval-log: «Approve (Recommended)» / «Godkjenn (anbefalt)» is the
  approval it labels; the suffix never turns a hollow answer into a yes.
- trekexecute Phase 8: the state-file block imports atomic-write through
  ${CLAUDE_PLUGIN_ROOT}; a relative import only ran with cwd = plugin root
  (the test runs the block from another directory).
- trekreview, trekresearch, trekcontinue: stats go through stats-append like
  the other commands, and a failed write is reported, never skipped
  silently (closes the open STATE finding for all three). The schema
  fixture's stale line refs for these and trekplan are re-measured.
- annotate prints an absolute path, so file://{$ANNOT_HTML} is a live link.
- red-proof: `red_kind` = load-error | test-failure on a proven red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:53:15 +02:00

339 lines
16 KiB
JavaScript

// lib/util/approval-log.mjs
// The approval log (K2): an append-only, hash-chained record of what the
// operator approved, bound to the sha256 of the approved content. One log per
// target repo, at .claude/voyage/approvals.jsonl; it is committed to the target
// repo (operator decision 2026-10-06: the log always goes to git, the brief and
// the plan only when the operator chooses so). Kinds: intent, plan, review,
// merge, release. Nothing but this CLI reads or writes the file.
//
// Adapted from bashebr/ai-native-sdlc@11fb8d7 (MIT, © 2026 AI-Native SDLC
// contributors): its gate ledger (prev_hash + hash per record, verify walks the
// chain). Two of its flaws are corrected here: an approval there is not bound to
// the approved content (here every record carries the content's sha256 and
// `verify --sha256` checks the current content against it), and the agent can
// write its own approval unseen (here the log is committed, `verify` reports
// records that are not committed yet, and a rewrite of committed records is
// `diverged`).
//
// WHAT THE LOG DOES NOT PROVE. Same user, same machine: any session that can run
// this CLI can append an approval, exactly as it can run
// `intent-approval.mjs --stamp`. A record is a TRACE that the approval step ran
// against this content, NOT a signature binding a person. What it does catch:
// - an edited, deleted or reordered record that was not re-hashed (`broken`);
// - content changed after approval (`mismatch` for that subject);
// - an approval that never happened (`not-logged`);
// - a rewrite that re-hashes the whole chain, once the old chain was committed
// (`diverged`: the committed log is no longer a prefix of the file). Before the
// first commit, a full re-hash is invisible to the chain; git history is the
// only witness, which is why every approval is committed right away.
// An approval a session writes and commits on the operator's behalf is visible as
// a commit in `git log`, not prevented. That is the trust class, stated openly.
//
// CLI (exit 0 = ok, 1 = broken chain / refused, 2 = could not run; 2 is never green):
// node lib/util/approval-log.mjs append --kind <kind> --subject <path>
// (--sha256 <hex | sha256:hex> | --sha256-of <file>) [--expires <ISO>] [--repo <dir>]
// node lib/util/approval-log.mjs verify [--json] [--repo <dir>]
// [--kind <kind> --subject <path> (--sha256 <hex> | --sha256-of <file>)]
// node lib/util/approval-log.mjs answer [--text <answer>] (stdin when --text is absent)
// node lib/util/approval-log.mjs digest <file>
// The repo is --repo, else the git top level of the working directory, else the
// working directory. A subject inside the repo is stored relative to it.
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import {
appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmdirSync, statSync,
} from 'node:fs';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
export const APPROVAL_KINDS = Object.freeze(['intent', 'plan', 'review', 'merge', 'release']);
export const LOG_RELPATH = '.claude/voyage/approvals.jsonl';
export const GENESIS = '0'.repeat(64);
const FIELDS = Object.freeze(['v', 'seq', 'ts', 'kind', 'subject', 'sha256', 'expires', 'prev']);
const HEX_RE = /^[0-9a-f]{64}$/;
const EXIT = Object.freeze({
intact: 0, empty: 0, approved: 0,
broken: 1, diverged: 1, 'not-logged': 1, mismatch: 1, expired: 1,
error: 2,
});
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
function realOr(p) {
try { return realpathSync(p); } catch { /* absent */ }
try { return join(realpathSync(dirname(p)), basename(p)); } catch { return resolve(p); }
}
/** The repo the log belongs to: explicit, else git top level of cwd, else cwd. */
export function resolveRepo(explicit, cwd = process.cwd()) {
if (explicit) return realOr(resolve(cwd, explicit));
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
if (r.status === 0 && r.stdout.trim()) return realOr(r.stdout.trim());
return realOr(cwd);
}
export function logPath(repo) { return join(repo, ...LOG_RELPATH.split('/')); }
/** A subject inside the repo is stored repo-relative with forward slashes; outside, absolute. */
export function normalizeSubject(repo, subject, cwd = process.cwd()) {
const real = realOr(resolve(cwd, subject));
const rel = relative(repo, real);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return rel.split(sep).join('/');
return real;
}
/** "sha256:<hex>" or "<hex>" → lowercase hex, or null. */
export function normalizeHex(value) {
const v = String(value ?? '').trim().replace(/^sha256:/, '').toLowerCase();
return HEX_RE.test(v) ? v : null;
}
export function digestFile(file) {
return sha256(readFileSync(file));
}
export function recordHash(rec) {
const canon = {};
for (const k of FIELDS) canon[k] = rec[k] ?? null;
return sha256(JSON.stringify(canon));
}
/** Read and walk the chain. @returns {{ exists, lines, records, broken: null | {line, why}, error? }} */
export function readLog(repo) {
const p = logPath(repo);
if (!existsSync(p)) return { exists: false, lines: [], records: [], broken: null };
let text;
try {
if (statSync(p).isDirectory()) return { exists: true, lines: [], records: [], broken: null, error: `${p} is a directory` };
text = readFileSync(p, 'utf8');
} catch (e) {
return { exists: true, lines: [], records: [], broken: null, error: `cannot read ${p}: ${e.message}` };
}
const lines = text.split('\n');
if (lines[lines.length - 1] === '') lines.pop();
const records = [];
let prev = GENESIS;
for (let i = 0; i < lines.length; i++) {
const at = i + 1;
let rec;
try { rec = JSON.parse(lines[i]); } catch { return { exists: true, lines, records, broken: { line: at, why: 'not JSON' } }; }
const keys = rec && typeof rec === 'object' ? Object.keys(rec).sort().join(',') : '';
if (keys !== [...FIELDS, 'hash'].sort().join(',')) return { exists: true, lines, records, broken: { line: at, why: 'fields differ from a record' } };
if (rec.seq !== at) return { exists: true, lines, records, broken: { line: at, why: `seq ${rec.seq}, expected ${at}` } };
if (rec.prev !== prev) return { exists: true, lines, records, broken: { line: at, why: 'prev does not link to the record before' } };
if (recordHash(rec) !== rec.hash) return { exists: true, lines, records, broken: { line: at, why: 'hash does not match the record' } };
records.push(rec);
prev = rec.hash;
}
return { exists: true, lines, records, broken: null };
}
/** The committed log's lines, or null when there is no git, no HEAD or the log is not tracked. */
function committedLines(repo) {
const r = spawnSync('git', ['-C', repo, 'show', `HEAD:${LOG_RELPATH}`], { encoding: 'utf8' });
if (r.status !== 0) return null;
const lines = r.stdout.split('\n');
if (lines[lines.length - 1] === '') lines.pop();
return lines;
}
function inGit(repo) {
return spawnSync('git', ['-C', repo, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' }).status === 0;
}
/**
* Verify the chain, and optionally that `subject`'s latest `kind` record approves `sha`.
* @returns {{ status: string, exit: number, records: number, uncommitted: number|null, detail?: string, record?: object }}
*/
export function verifyLog({ repo, kind, subject, sha, now = new Date() }) {
const log = readLog(repo);
const base = { log: logPath(repo), records: log.records.length, uncommitted: null };
if (log.error) return { ...base, status: 'error', exit: 2, detail: log.error };
if (log.broken) return { ...base, status: 'broken', exit: 1, detail: `line ${log.broken.line}: ${log.broken.why}` };
if (log.exists && inGit(repo)) {
const head = committedLines(repo) || [];
const prefix = head.length <= log.lines.length && head.every((l, i) => l === log.lines[i]);
if (!prefix) {
return { ...base, status: 'diverged', exit: 1, detail: 'the committed log is not a prefix of the file: committed approvals were rewritten or removed' };
}
base.uncommitted = log.lines.length - head.length;
}
if (subject === undefined) {
const status = log.records.length ? 'intact' : 'empty';
return { ...base, status, exit: EXIT[status] };
}
const latest = [...log.records].reverse().find((r) => r.kind === kind && r.subject === subject);
if (!latest) return { ...base, status: 'not-logged', exit: 1, detail: `no ${kind} approval for ${subject} in the log` };
if (latest.sha256 !== sha) {
return { ...base, status: 'mismatch', exit: 1, record: latest, detail: `the latest ${kind} approval for ${subject} covers other content` };
}
if (latest.expires && Date.parse(latest.expires) <= now.getTime()) {
return { ...base, status: 'expired', exit: 1, record: latest, detail: `the approval expired ${latest.expires}` };
}
return { ...base, status: 'approved', exit: 0, record: latest };
}
function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
function withLock(p, fn) {
const lock = `${p}.lock`;
for (let i = 0; i < 200; i++) {
try { mkdirSync(lock); break; } catch (e) {
if (e.code !== 'EEXIST') throw e;
try { if (Date.now() - statSync(lock).mtimeMs > 10_000) rmdirSync(lock); } catch { /* raced */ }
if (i === 199) throw new Error(`log is locked: ${lock}`);
sleep(25);
}
}
try { return fn(); } finally { try { rmdirSync(lock); } catch { /* gone */ } }
}
/**
* Append one approval. Refuses (exit 1) a bad kind/subject/hash/expiry or a log
* that does not verify; never extends a broken or diverged chain.
*/
export function appendApproval({ repo, kind, subject, sha, expires, now = new Date() }) {
if (!APPROVAL_KINDS.includes(kind)) return { appended: false, exit: 1, reason: `kind "${kind}" not in [${APPROVAL_KINDS.join(', ')}]` };
if (!subject) return { appended: false, exit: 1, reason: '--subject is required' };
if (!sha) return { appended: false, exit: 1, reason: '--sha256 must be 64 hex characters (optionally prefixed "sha256:")' };
let exp = null;
if (expires !== undefined) {
const t = Date.parse(expires);
if (Number.isNaN(t)) return { appended: false, exit: 1, reason: `--expires "${expires}" is not an ISO-8601 time` };
exp = new Date(t).toISOString();
}
const p = logPath(repo);
try {
mkdirSync(dirname(p), { recursive: true });
return withLock(p, () => {
const v = verifyLog({ repo, now });
if (v.exit === 2) return { appended: false, exit: 2, reason: v.detail };
if (v.exit === 1) return { appended: false, exit: 1, reason: `the log does not verify (${v.status}: ${v.detail}); nothing appended` };
const log = readLog(repo);
const last = log.records[log.records.length - 1];
const rec = {
v: 1, seq: log.records.length + 1, ts: now.toISOString(), kind, subject, sha256: sha,
expires: exp, prev: last ? last.hash : GENESIS,
};
rec.hash = recordHash(rec);
let raw = '';
try { raw = readFileSync(p, 'utf8'); } catch { /* new log */ }
appendFileSync(p, (raw && !raw.endsWith('\n') ? '\n' : '') + JSON.stringify(rec) + '\n');
return { appended: true, exit: 0, record: rec, log: p };
});
} catch (e) {
return { appended: false, exit: 2, reason: e.message };
}
}
// Answers that are not approval. Adapted from addyosmani/agent-skills@9d0c60d
// (MIT, © 2025 Addy Osmani), skills/interview-me: «Sounds good» and «Whatever
// you think» are not a yes. Only an explicit approval counts; everything else,
// the empty answer included, is not one.
const EXPLICIT = new Set([
'approve', 'approved', 'i approve', 'yes', 'yes, approve', 'yes approve',
'godkjenn', 'godkjent', 'godkjenner', 'jeg godkjenner', 'ja', 'ja, godkjent', 'ja godkjent',
]);
const HOLLOW = [
'høres bra ut', 'hores bra ut', 'gjør som du vil', 'gjor som du vil', 'ser bra ut', 'greit nok',
'sounds good', 'whatever you think', 'looks good', 'lgtm', 'up to you', 'your call', 'fine by me',
'ok', 'okay', 'sure', 'fine', 'kjør på', 'kjor pa',
];
/** @returns {{ approved: boolean, reason: 'explicit'|'empty'|'hollow'|'not-explicit' }} */
// AskUserQuestion's own guidance puts «(Recommended)» after the label it
// recommends, so the operator's real «Approve (Recommended)» was refused in the
// voyage-lekerepo trial (2026-10-06). The suffix is stripped; what is left must
// still be an explicit approval on its own.
const RECOMMENDED_SUFFIX = /\s*\((?:recommended|anbefalt)\)\s*$/u;
export function isExplicitApproval(answer) {
const t = String(answer ?? '').trim().toLowerCase().replace(/[\s.!]+$/u, '')
.replace(RECOMMENDED_SUFFIX, '').replace(/[\s.!]+$/u, '').replace(/\s+/g, ' ');
if (!t) return { approved: false, reason: 'empty' };
if (EXPLICIT.has(t)) return { approved: true, reason: 'explicit' };
if (HOLLOW.some((h) => t === h || (h.includes(' ') && t.includes(h)))) return { approved: false, reason: 'hollow' };
return { approved: false, reason: 'not-explicit' };
}
// ---- CLI ----
function opt(args, name) {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function out(obj, code) {
process.stdout.write(JSON.stringify(obj) + '\n');
process.exit(code);
}
function shaFromArgs(args) {
const of = opt(args, '--sha256-of');
if (of !== undefined) {
try { return { sha: digestFile(of) }; } catch (e) { return { error: `cannot read ${of}: ${e.message}` }; }
}
const raw = opt(args, '--sha256');
return { sha: raw === undefined ? undefined : normalizeHex(raw), raw };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'answer') {
let text = opt(args, '--text');
if (text === undefined) { try { text = readFileSync(0, 'utf8'); } catch { text = ''; } }
const r = isExplicitApproval(text);
out(r, r.approved ? 0 : 1);
}
if (cmd === 'digest') {
const file = args.find((a) => !a.startsWith('--'));
try { process.stdout.write(digestFile(file) + '\n'); process.exit(0); } catch (e) {
process.stderr.write(`approval-log digest: cannot read ${file}: ${e.message}\n`);
process.exit(2);
}
}
if (cmd !== 'append' && cmd !== 'verify') {
process.stderr.write('Usage: approval-log.mjs append|verify|answer|digest — see the header of lib/util/approval-log.mjs\n');
process.exit(2);
}
const repo = resolveRepo(opt(args, '--repo'));
const kind = opt(args, '--kind');
const subjectArg = opt(args, '--subject');
const { sha, raw, error } = shaFromArgs(args);
if (cmd === 'append') {
if (error) out({ appended: false, reason: error }, 2);
const r = appendApproval({
repo, kind, subject: subjectArg ? normalizeSubject(repo, subjectArg) : '',
sha: raw !== undefined || sha ? sha : undefined, expires: opt(args, '--expires'),
});
const { exit, ...rest } = r;
out(rest, exit);
}
// verify
const filtered = [kind, subjectArg, sha ?? raw ?? error].filter((x) => x !== undefined).length;
let r;
if (error) r = { status: 'error', exit: 2, detail: error };
else if (filtered !== 0 && filtered !== 3) r = { status: 'error', exit: 2, detail: 'a subject check needs --kind, --subject and --sha256 (or --sha256-of) together' };
else if (filtered === 3 && !sha) r = { status: 'error', exit: 2, detail: `--sha256 "${raw}" is not 64 hex characters` };
else if (filtered === 3 && !APPROVAL_KINDS.includes(kind)) r = { status: 'error', exit: 2, detail: `kind "${kind}" not in [${APPROVAL_KINDS.join(', ')}]` };
else r = verifyLog({ repo, kind, subject: filtered === 3 ? normalizeSubject(repo, subjectArg) : undefined, sha });
const { exit, ...rest } = r;
if (args.includes('--json')) {
process.stdout.write(JSON.stringify(rest) + '\n');
} else {
process.stdout.write(`approval-log: ${rest.status}${rest.detail ? ` — ${rest.detail}` : ''}\n`);
if (rest.uncommitted) process.stdout.write(` ${rest.uncommitted} approval(s) not committed yet\n`);
}
process.exit(exit);
}