voyage/lib/review/threat-model.mjs
Kjell Tore Guttormsen d402b0088b
fix(review): the review's fix round - engines' key in rule_key, --brief only with --record, the gate's threat model from the review's work tree
The one narrow fix round after /code-review high on E (three findings of
new classes; the between-rounds findings are known issues, decision 226):

- threat-model.mjs isOwnSecurityFinding: the engines' key in rule_key is
  never Voyage's own, also when rule_keys names a key of its own (keysOf
  reads rule_keys alone when it is a list, so an entry with rule_key
  SECURITY_REVIEW_VULNERABILITY and rule_keys [SECURITY_RISK] could be
  backed, against decision 218).
- plan-gate.mjs: --brief without --record is a usage error (exit 2); the
  bare gate and --accept-open read the verdict's brief_path, and a --brief
  there was silently ignored.
- verdict-gate.mjs: the known_outside check reads the threat model from the
  answers' repo (--repo, else the review file's own work tree); with no
  work tree nothing is backed. A review outside any work tree, gated from
  another repo, was backed by that repo's brief (ALLOW).

Each with a test seen failing on an assertion first.

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

200 lines
10 KiB
JavaScript

// lib/review/threat-model.mjs
// One reader of the brief's optional `## Threat Model` (E, decisions 164, 218),
// for both gates: the plan gate (lib/validators/plan-gate.mjs) and the review's
// verdict gate (lib/review/verdict-gate.mjs).
//
// The section is `- Inside: <text>` / `- Outside: <text>` bullets
// (brief-validator.mjs `parseThreatModel`, the same reader the validator uses).
// Voyage's own security findings (plan-critic, scope-guardian, the code
// reviewer's part for command texts) block inside it; one that quotes an
// `Outside:` line's text exactly in `threat_model_ref` is backed by that line
// and becomes a listed known issue instead of a blocker. Engine findings
// (Claude Code's `/security-review`, Claude Security) block as before: only a
// security finding not from an engine can be outside (218).
//
// readThreatModel(briefText) -> {present, inside, outside, problems}
// usable(briefPath, repo) -> {usable, why, threatModel}
// usable = the section is present and well formed, AND the intent approval
// is current: the two checks /trekplan Phase 1 runs (the marker,
// checkIntentApprovalContent; and the approval log, verifyLog kind
// `intent` with the hash's `sha256:` stripped and the subject normalized).
// Otherwise every security finding counts, and `why` says why.
// backedLine(finding, tm) -> the `Outside:` text the finding's
// `threat_model_ref` equals exactly, else null.
// splitBacked(entries, use, asFinding) -> {backed, unbacked, why}: the one
// backing check of both gates' `known_outside` (backedLine under a usable
// threat model; `why` says why the unbacked ones are not backed).
// usableInRepo(briefPath, repo) -> usable() for a brief path relative to
// the repo's top level (an absolute one as it is); both gates read a
// stored `brief_path` this way, whatever directory they run in.
// repoRelativeBrief(briefPath, repo) -> a brief path given from the
// working directory, made relative to the repo's top level (forward
// slashes; `..` when outside it), never absolute: what the plan gate
// stores as `brief_path`.
// reviewThreatModel(reviewText, repo) -> usableInRepo() for the brief a
// review names in its `brief_path`; the review side's `known_outside`
// (E 2.5) is checked with it.
// isKnownEntry(e) -> a review `known_outside` entry's shape: a
// finding with a string `rule_key` and `file`, an integer `line` >= 0
// and a non-empty `threat_model_ref`.
//
// Lifting a finding back in is the operator's: change (or remove) that
// `Outside:` line and approve the intent again. Both gates re-read the brief on
// every run, so a finding whose line is gone counts in full.
import { readFileSync } from 'node:fs';
import { isAbsolute, join, relative, sep } from 'node:path';
import { parseDocument } from '../util/frontmatter.mjs';
import { verifyLog, normalizeHex, normalizeSubject, resolveRepo } from '../util/approval-log.mjs';
import { checkIntentApprovalContent } from '../validators/intent-approval.mjs';
import { parseThreatModel } from '../validators/brief-validator.mjs';
import { isSecurity, keysOf } from './plan-review-dedup.mjs';
import { ENGINES, SECURITY_ENGINES } from './engine-findings.mjs';
/** The security engines' catalogue keys (/security-review, Claude Security): never Voyage's own. */
const ENGINE_SECURITY_KEYS = new Set(SECURITY_ENGINES.map((e) => ENGINES[e].rule_key));
const EMPTY = Object.freeze({ present: false, inside: [], outside: [], problems: [] });
/**
* Read the brief's `## Threat Model`.
* @param {string} briefText - the whole brief (frontmatter optional)
* @returns {{present: boolean, inside: string[], outside: string[], problems: string[]}}
*/
export function readThreatModel(briefText) {
const text = typeof briefText === 'string' ? briefText : '';
const doc = parseDocument(text);
const body = doc.parsed?.body ?? text;
return parseThreatModel(body);
}
/**
* May the brief's threat model be applied? Present, well formed, and the
* intent approval current (marker and log). Otherwise every security finding
* counts, and `why` says why.
* @param {string} briefPath
* @param {string} [repo] - the repo whose approval log to read (default: the git top level of the working directory)
* @returns {{usable: boolean, why: string|null, threatModel: {present: boolean, inside: string[], outside: string[], problems: string[]}}}
*/
export function usable(briefPath, repo) {
const no = (why, threatModel = EMPTY) => ({ usable: false, why, threatModel });
if (typeof briefPath !== 'string' || briefPath === '') return no('no brief path was given');
let text;
try { text = readFileSync(briefPath, 'utf8'); } catch (e) { return no(`cannot read the brief ${briefPath}: ${e.message}`); }
const tm = readThreatModel(text);
if (!tm.present) return no('the brief has no ## Threat Model', tm);
if (tm.problems.length > 0) return no(`the brief's ## Threat Model is malformed: ${tm.problems.join('; ')}`, tm);
const marker = checkIntentApprovalContent(text);
if (!marker.valid) {
return no(`the intent approval is not current (${marker.errors.map((e) => e.code).join(', ')})`, tm);
}
const sha = normalizeHex(marker.parsed?.frontmatter?.intent_approved_hash);
if (!sha) return no('the brief carries no intent approval hash', tm);
let log;
try {
const root = resolveRepo(repo);
log = verifyLog({ repo: root, kind: 'intent', subject: normalizeSubject(root, briefPath), sha });
} catch (e) {
return no(`the approval log could not be read: ${e.message}`, tm);
}
if (log.exit !== 0) {
return no(`the intent approval is not in the approval log (${log.status}${log.detail ? `: ${log.detail}` : ''})`, tm);
}
return { usable: true, why: null, threatModel: tm };
}
/**
* usable() for a brief path relative to the repo's top level (an absolute
* path as it is), so a stored `brief_path` reads the same brief from any
* working directory.
* @param {string} briefPath
* @param {string} [repo] - the repo (default: the git top level of the working directory)
* @returns {{usable: boolean, why: string|null, threatModel: object}}
*/
export function usableInRepo(briefPath, repo) {
if (typeof briefPath !== 'string' || briefPath.trim() === '') return { usable: false, why: 'no brief path was given', threatModel: EMPTY };
let root;
try { root = resolveRepo(repo); } catch (e) {
return { usable: false, why: `the repo could not be read: ${e.message}`, threatModel: EMPTY };
}
return usable(isAbsolute(briefPath) ? briefPath : join(root, briefPath), root);
}
/**
* A brief path given from the working directory, relative to the repo's top
* level: forward slashes, `..` when the brief is outside the repo, never absolute.
* @param {string} briefPath
* @param {string} [repo] - the repo (default: the git top level of the working directory)
* @returns {string}
*/
export function repoRelativeBrief(briefPath, repo) {
const root = resolveRepo(repo);
const s = normalizeSubject(root, briefPath);
return isAbsolute(s) ? relative(root, s).split(sep).join('/') : s;
}
/**
* The threat model a review applies: usableInRepo() for the brief at the
* review's frontmatter `brief_path`, a relative path read from the repo's top level.
* @param {string} reviewText - review.md
* @param {string} [repo] - the repo (default: the git top level of the working directory)
* @returns {{usable: boolean, why: string|null, threatModel: object}}
*/
export function reviewThreatModel(reviewText, repo) {
const doc = parseDocument(typeof reviewText === 'string' ? reviewText : '');
const bp = doc.parsed?.frontmatter?.brief_path;
if (typeof bp !== 'string' || bp.trim() === '') return { usable: false, why: 'the review names no brief_path', threatModel: EMPTY };
return usableInRepo(bp, repo);
}
/** A review `known_outside` entry's shape (E 2.5): string rule_key and file, integer line >= 0, non-empty threat_model_ref. */
export function isKnownEntry(e) {
return e !== null && typeof e === 'object' && !Array.isArray(e) &&
typeof e.rule_key === 'string' && e.rule_key !== '' && typeof e.file === 'string' && e.file !== '' &&
Number.isInteger(e.line) && e.line >= 0 && typeof e.threat_model_ref === 'string' && e.threat_model_ref !== '';
}
/** A security finding that is Voyage's own: a security key, no engine, not the engines' key (218). */
export function isOwnSecurityFinding(f) {
if (!f || typeof f !== 'object') return false;
if (typeof f.engine === 'string' && f.engine.trim() !== '') return false;
// The engines' key in rule_key or in rule_keys: keysOf reads rule_keys alone when it is a list.
const named = [f.rule_key, ...(Array.isArray(f.rule_keys) ? f.rule_keys : [])].filter((k) => typeof k === 'string');
if (named.some((k) => ENGINE_SECURITY_KEYS.has(k.trim().toUpperCase()))) return false;
return isSecurity(f);
}
/**
* The `Outside:` text a finding is backed by: its `threat_model_ref` equals it
* exactly. Only Voyage's own security findings can be backed.
* @param {object} finding
* @param {{present?: boolean, outside?: string[]}|null} tm - from readThreatModel / usable
* @returns {string|null}
*/
export function backedLine(finding, tm) {
if (!tm || tm.present !== true || !Array.isArray(tm.outside)) return null;
if (!isOwnSecurityFinding(finding)) return null;
const ref = finding.threat_model_ref;
if (typeof ref !== 'string' || ref === '') return null;
return tm.outside.find((line) => line === ref) ?? null;
}
const NOT_BACKED = 'no `Outside:` line of the brief equals the text it quotes, or it is not a well-formed Voyage security finding';
/**
* Split entries on the threat model `use` (from usable / usableInRepo /
* reviewThreatModel): backed when backedLine finds the `Outside:` line of
* `asFinding(entry)`; a threat model that is not usable backs nothing.
* @param {Array<*>} entries
* @param {{usable: boolean, why: string|null, threatModel: object}|undefined} use
* @param {(e: *) => *} [asFinding] - the finding backedLine checks for an entry (default: the entry)
* @returns {{backed: Array<*>, unbacked: Array<*>, why: string|null}} why = why the unbacked ones are not backed (null when none is)
*/
export function splitBacked(entries, use, asFinding = (e) => e) {
const tm = use?.usable === true ? use.threatModel : null;
const backed = [];
const unbacked = [];
for (const e of entries) (backedLine(asFinding(e), tm) !== null ? backed : unbacked).push(e);
if (unbacked.length === 0) return { backed, unbacked, why: null };
return { backed, unbacked, why: tm ? NOT_BACKED : (use?.why ?? 'the threat model is not usable') };
}