voyage/lib/validators/intent-approval.mjs
Kjell Tore Guttormsen 74f75dcb7d
feat(brief): optional threat model in the brief (decision 164, 218)
A brief may carry an optional `## Threat Model` with one line per bullet,
`- Inside: <text>` or `- Outside: <text>`. Present and malformed (no such
bullet, another label or form, empty text, two sections) is
BRIEF_THREAT_MODEL_MALFORMED; absent is today's rule. No brief_version bump
(the ceremony / artifact_store precedent). Sections are found fence- and
comment-aware through intent-approval's new sectionTexts, so the validator
and the hash count the same sections; brief-validator exports
parseThreatModel for the reader.

The intent hash covers `## Threat Model` when present (two sections -> null
hash, as Intent and Goal), so an edited, added or removed threat model
makes the approval stale; a brief without one hashes exactly as before. The
stale message and the /trekplan halt table name the section.

/trekbrief offers the section in its question round when the task touches
a guard, a gate, untrusted input, secrets or publishing, writes it only
when given, shows it verbatim at the Phase 4h approval, and --approve reads
it out. The template documents it; brief-gate-coverage pairs the new code.

Part 2, step 2.1 of the E plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:47:51 +01:00

295 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/validators/intent-approval.mjs
// Intent approval marker — the gate /trekplan passes before it plans anything.
//
// A brief carries two optional frontmatter fields once the operator has
// approved its intent in /trekbrief (Phase 4h, or `/trekbrief --approve`):
//
// intent_approved_hash: "sha256:<64 hex>" hash over ## Intent + ## Goal
// (+ ## Threat Model when present)
// intent_approved_at: "<ISO-8601>" when the stamp was written
//
// The hash binds the approval to the EXACT intent text: each section is
// normalized (lines trimmed, whitespace runs collapsed, blank lines dropped)
// and the pair is hashed. The optional ## Threat Model (E, decision 164) is
// part of what the operator approves: when the brief has one it joins the
// hash, so editing, adding or removing it makes the approval stale; a brief
// without one hashes exactly as before. Indentation, spacing inside a line,
// blank lines and CRLF keep the approval. Line breaks are part of the text:
// joining or splitting lines makes the approval stale, as does changing a
// word. That errs safe: /trekplan stops until the operator approves the new text.
// Each section must appear exactly once (## Threat Model at most once); a `## `
// line inside a fenced code block or an HTML comment is text, not a heading.
//
// WHAT THE MARKER DOES NOT PROVE. Same user, same machine: any session with
// write access to the brief can run `--stamp` itself, or write the two fields
// by hand. The marker is a TRACE that the approval step ran against this text,
// NOT a signature binding a person — the same limit as the order queue's
// `--from`. It catches drift (intent edited after approval) and omission (no
// approval step at all); it does not catch a session that approves on the
// operator's behalf.
//
// trekreview briefs are exempt: they are produced from a review of an already
// planned brief and carry no ## Intent of their own. The exemption is read
// from the brief's own frontmatter, so relabelling a brief `type: trekreview`
// walks past the gate. That is the same trust class as self-stamping: a
// session that can edit the brief can do either. Neither is caught here.
//
// CLI:
// node lib/validators/intent-approval.mjs --check [--json] <brief.md>
// exit 0 = approved and current; exit 1 = stop (errors); exit 2 = usage
// node lib/validators/intent-approval.mjs --stamp <brief.md>
// writes the marker; exit 0 stamped, 1 refused
// node lib/validators/intent-approval.mjs --resolve <project-dir | brief-file>
// what `/trekbrief --approve` approves: {brief_path, project_dir, plan_command}; exit 1 = no brief
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
export const INTENT_HASH_FIELD = 'intent_approved_hash';
export const INTENT_APPROVED_AT_FIELD = 'intent_approved_at';
export const INTENT_SECTIONS = Object.freeze(['Intent', 'Goal']);
/** The optional section the intent approval also covers when present (E 2.1). */
export const THREAT_MODEL_SECTION = 'Threat Model';
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
const APPROVE_HINT = 'Run /trekbrief --approve <project-dir | brief-file> — the operator reads ## Intent + ## Goal (+ ## Threat Model) and answers "Approve".';
/**
* Line indexes of the `## ` headings that are real headings: a `## ` line
* inside a fenced code block or an HTML comment is text, not a heading. Seeing
* it as one let an example `## Intent` capture the hash, or cut the real
* section short (M1).
*/
function headingLines(lines) {
const out = [];
let fence = null; // { ch, len } of the open fence
let inComment = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (fence) {
const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
if (close && close[1][0] === fence.ch && close[1].length >= fence.len) fence = null;
continue;
}
if (inComment) {
if (line.includes('-->')) inComment = false;
continue;
}
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
if (open) { fence = { ch: open[1][0], len: open[1].length }; continue; }
if (/^##\s/.test(line)) out.push(i);
const c = line.lastIndexOf('<!--');
if (c >= 0 && line.indexOf('-->', c + 4) < 0) inComment = true;
}
return out;
}
/** Every `## <heading>` section's text (one entry per occurrence). */
function extractSections(lines, heads, heading) {
const re = new RegExp(`^##\\s+${heading}\\b`);
return heads.filter((i) => re.test(lines[i])).map((i) => {
const next = heads.find((j) => j > i);
return lines.slice(i + 1, next === undefined ? lines.length : next).join('\n');
});
}
/**
* Every real `## <heading>` section's text in a brief body, one entry per
* occurrence; a `## ` line inside a fenced code block or an HTML comment is
* text, not a heading. The brief validator and the threat-model reader use it,
* so they count sections exactly as the intent hash does.
* @param {string} body
* @param {string} heading
* @returns {string[]}
*/
export function sectionTexts(body, heading) {
const lines = (body || '').split('\n');
return extractSections(lines, headingLines(lines), heading);
}
function normalize(text) {
return text.split(/\r?\n/)
.map((l) => l.trim().replace(/\s+/g, ' '))
.filter(Boolean)
.join('\n');
}
/**
* Hash ## Intent + ## Goal (+ ## Threat Model when present) of a brief body.
* @returns {{ hash: string|null, missing: string[], duplicate: string[] }} hash is null when
* ## Intent or ## Goal is absent or empty, or any of the three appears more than once (which
* one would the approval cover?).
*/
export function computeIntentHash(body) {
const lines = (body || '').split('\n');
const heads = headingLines(lines);
const parts = [];
const missing = [];
const duplicate = [];
for (const h of INTENT_SECTIONS) {
const found = extractSections(lines, heads, h);
if (found.length > 1) duplicate.push(h);
const n = found.length === 1 ? normalize(found[0]) : '';
if (!n && found.length <= 1) missing.push(h);
parts.push(n);
}
const threat = extractSections(lines, heads, THREAT_MODEL_SECTION);
if (threat.length > 1) duplicate.push(THREAT_MODEL_SECTION);
if (missing.length || duplicate.length) return { hash: null, missing, duplicate };
// Absent: two parts, the hash every brief had before E. Present: a third part.
if (threat.length === 1) parts.push(normalize(threat[0]));
const hex = createHash('sha256').update(JSON.stringify(parts), 'utf8').digest('hex');
return { hash: `sha256:${hex}`, missing, duplicate };
}
/** Why the approval cannot bind, in words. */
function unbindable({ missing, duplicate }) {
const why = [];
if (duplicate.length) why.push(`## ${duplicate.join(' and ## ')} appears more than once`);
if (missing.length) why.push(`## ${missing.join(' and ## ')} is missing or empty`);
return why.join('; ');
}
/** Check the approval marker of a brief's text. Returns a validator Result. */
export function checkIntentApprovalContent(text) {
const doc = parseDocument(text);
if (!doc.valid) return doc;
const fm = doc.parsed.frontmatter || {};
const body = doc.parsed.body || '';
const errors = [];
const result = () => ({ valid: errors.length === 0, errors, warnings: [], parsed: { frontmatter: fm } });
if (fm.type === 'trekreview') return result();
if (!(INTENT_HASH_FIELD in fm)) {
errors.push(issue(
'BRIEF_INTENT_NOT_APPROVED',
'The brief\'s intent has not been approved — /trekplan does not plan against an unapproved intent.',
APPROVE_HINT,
));
return result();
}
const recorded = String(fm[INTENT_HASH_FIELD]);
if (!HASH_RE.test(recorded)) {
errors.push(issue(
'BRIEF_INTENT_APPROVAL_INVALID',
`${INTENT_HASH_FIELD} "${recorded}" is not "sha256:<64 hex>" — the marker is malformed.`,
APPROVE_HINT,
));
return result();
}
const bound = computeIntentHash(body);
const { hash } = bound;
if (!hash) {
errors.push(issue(
'BRIEF_INTENT_APPROVAL_INVALID',
`The approval cannot bind: ${unbindable(bound)}.`,
'Leave exactly one non-empty ## Intent and one ## Goal, and at most one ## Threat Model, then ' + APPROVE_HINT,
));
return result();
}
if (hash !== recorded) {
errors.push(issue(
'BRIEF_INTENT_APPROVAL_STALE',
'## Intent, ## Goal or ## Threat Model changed (or a ## Threat Model was added or removed) after the intent was approved — the approval covers the old text, not this one.',
APPROVE_HINT,
));
}
return result();
}
/**
* Write the approval marker into a brief's frontmatter (replacing any prior one).
* @returns {{ stamped: boolean, text: string, hash?: string, approvedAt?: string, reason?: string }}
*/
export function stampIntentApproval(text, now = new Date()) {
const doc = parseDocument(text);
if (!doc.valid) return { stamped: false, text, reason: 'frontmatter does not parse' };
const bound = computeIntentHash(doc.parsed.body || '');
const { hash } = bound;
if (!hash) return { stamped: false, text, reason: unbindable(bound) };
const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---)/.exec(text);
if (!m) return { stamped: false, text, reason: 'no frontmatter block' };
const approvedAt = now.toISOString();
const kept = m[2].split(/\r?\n/).filter((l) =>
!l.startsWith(`${INTENT_HASH_FIELD}:`) && !l.startsWith(`${INTENT_APPROVED_AT_FIELD}:`));
kept.push(`${INTENT_HASH_FIELD}: "${hash}"`, `${INTENT_APPROVED_AT_FIELD}: "${approvedAt}"`);
const out = m[1] + kept.join('\n') + m[3] + text.slice(m.index + m[0].length);
return { stamped: true, text: out, hash, approvedAt };
}
/**
* What `/trekbrief --approve <arg>` approves. A directory means `<dir>/brief.md`
* (a /trekplan --project brief); a file is itself (a /trekplan --brief brief,
* whatever its name). Before M2 only `<dir>/brief.md` was reachable.
* @returns {{ brief_path: string, project_dir: string, plan_command: string } | { error: string }}
*/
export function resolveApproveTarget(arg) {
const target = String(arg || '').replace(/\/+$/, '');
if (!target) return { error: 'no brief given — /trekbrief --approve <project-dir | brief-file>' };
let isDir = false;
try { isDir = statSync(target).isDirectory(); } catch { /* absent */ }
const briefPath = isDir ? join(target, 'brief.md') : target;
if (!existsSync(briefPath) || statSync(briefPath).isDirectory()) {
return { error: `no brief at ${briefPath} — run /trekbrief first` };
}
const projectDir = dirname(briefPath);
const planCommand = basename(briefPath) === 'brief.md'
? `/trekplan --project ${projectDir}`
: `/trekplan --brief ${briefPath}`;
return { brief_path: briefPath, project_dir: projectDir, plan_command: planCommand };
}
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith('--'));
if (args.includes('--resolve')) {
const r = resolveApproveTarget(file);
process.stdout.write(JSON.stringify(r) + '\n');
process.exit(r.error ? 1 : 0);
}
const mode = args.includes('--stamp') ? 'stamp' : args.includes('--check') ? 'check' : null;
if (!file || !mode) {
process.stderr.write('Usage: intent-approval.mjs --check [--json] <brief.md> | --stamp <brief.md> | --resolve <project-dir | brief-file>\n');
process.exit(2);
}
let text = null;
if (existsSync(file)) {
try { text = readFileSync(file, 'utf8'); } catch { text = null; }
}
if (mode === 'check') {
const r = text === null
? { valid: false, errors: [issue('BRIEF_NOT_FOUND', `Cannot read brief: ${file}`)], warnings: [] }
: checkIntentApprovalContent(text);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`intent-approval: ${r.valid ? 'APPROVED' : 'STOP'} ${file}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n ${e.hint || ''}\n`);
}
process.exit(r.valid ? 0 : 1);
}
// --stamp
if (text === null) {
process.stdout.write(JSON.stringify({ stamped: false, reason: `cannot read ${file}` }) + '\n');
process.exit(1);
}
const s = stampIntentApproval(text);
if (!s.stamped) {
process.stdout.write(JSON.stringify({ stamped: false, reason: s.reason }) + '\n');
process.exit(1);
}
const tmp = join(dirname(file), `.${basename(file)}.approve.tmp`);
writeFileSync(tmp, s.text);
renameSync(tmp, file);
process.stdout.write(JSON.stringify({
stamped: true, intent_hash: s.hash, approved_at: s.approvedAt,
}) + '\n');
process.exit(0);
}