The code-correctness reviewer's dimension 6 shrinks to what the engines do not read: command, skill and agent markdown carrying shell, with Claude Code's threshold (a concrete attack path, high confidence), the keys SECURITY_INJECTION or SECURITY_RISK, and threat_model_ref quoting the brief's Outside: line; the code-level bullets go to the engines. The coordinator puts a Voyage security finding with a threat_model_ref into the trailing JSON block's known_outside, never into findings. review-finalize gains the pass `outside`, right after engine: it only raises. An entry the brief at the review's brief_path does not back now (line gone, threat model not usable, an engine finding, not a security finding) goes back into the findings at its catalogue tier with a Source line, R-step and raised verdict, as the engine put-back does; the rest are listed under the new `## Outside the Threat Model` section. verdict-gate decideOpenFindings (and so scripts/release-check.mjs) re-checks every entry against brief_path through the new notBackedOutside and exits 2 for one not backed. review-validator shape-checks the optional known_outside (REVIEW_KNOWN_OUTSIDE_MALFORMED). threat-model.mjs gains reviewThreatModel and isKnownEntry for the three. /trekreview Phase 8.8 lists the known issues outside the threat model (not answered, not counted); the review template shows the section and field; Handover 1 names the optional ## Threat Model and its code, Handover 6 names known_outside and its code. Part 2, step 2.5 of the E plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
7.3 KiB
JavaScript
142 lines
7.3 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.
|
|
// reviewThreatModel(reviewText, repo) -> usable() for the brief a review
|
|
// names in its `brief_path` (a relative path is read from the repo's top
|
|
// level); 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 } 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 { 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 };
|
|
}
|
|
|
|
/**
|
|
* The threat model a review applies: usable() 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 };
|
|
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(bp) ? bp : join(root, bp), root);
|
|
}
|
|
|
|
/** 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;
|
|
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;
|
|
}
|