// 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: ` / `- Outside: ` 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. // // 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 { 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 { isSecurityKey, keysOf } from './plan-review-dedup.mjs'; /** The engines' catalogue key (/security-review, Claude Security): never Voyage's own. */ export const ENGINES_SECURITY_KEY = 'SECURITY_REVIEW_VULNERABILITY'; 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 }; } /** 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; const keys = keysOf(f); if (keys.some((k) => k.trim().toUpperCase() === ENGINES_SECURITY_KEY)) return false; return keys.some(isSecurityKey); } /** * 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; }