feat(review): the brief's threat model on the review side (decision 164, 167, 218)

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>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-08 09:14:47 +01:00
commit c47061191c
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
10 changed files with 585 additions and 30 deletions

View file

@ -2,7 +2,7 @@
// One deterministic pass over review.md after the coordinator and after
// lib/review/fill-finding-ids.mjs, on both paths (review_version 1.2, fix
// round 4.5b2 part B). Its passes run in the Architecture order (engine,
// instruction, carry, pre-existing, policy), each at its place in PASSES:
// outside, instruction, carry, pre-existing, policy), each at its place in PASSES:
//
// - engine, always the first pass: `putBack` (lib/review/engine-compare.mjs)
// compares the engine payloads Phase 4.7 saved in the project folder
@ -17,6 +17,18 @@
// the frontmatter list, counts, a verdict that is only raised, and, as a
// BLOCKER, its `### Step R<n>`. An engine finding in `out_of_scope` is
// not compared (it stays declined, `engine:out-of-scope`).
// - outside, right after engine (E 2.5, decisions 164, 218): the trailing
// JSON block's optional `known_outside` (Voyage's own security findings
// the coordinator listed as outside the brief's threat model) is checked
// against the brief at the review's `brief_path`
// (lib/review/threat-model.mjs `reviewThreatModel`, `backedLine`). It only
// raises: an entry the brief does not back now (its `Outside:` line gone,
// the threat model not usable, an engine finding, not a security finding)
// goes back into the findings at its catalogue tier with no id, a `### `
// entry with a `**Source:**` line saying why, the frontmatter list, counts,
// a verdict that is only raised and, as a BLOCKER, its `### Step R<n>`.
// The entries that stay are listed under `## Outside the Threat Model`;
// a malformed entry stays too (the verdict gate exits 2 on it).
// - instruction, right after engine: a finding under `CLAUDE_MD_DEVIATION`
// or `CLAUDE_MD_STALE` whose `instruction_ref` is missing or names a file
// outside the repo's tracked instruction set (lib/review/instruction-scope.mjs
@ -111,6 +123,8 @@ import { carry, frontmatterField, MAX_REVIEW_ROUNDS, runId } from './review-roun
import { applyPolicy, readPolicy } from './review-policy.mjs';
import { ENGINES } from './engine-findings.mjs';
import { ENGINE_OF_REVIEWER, putBack } from './engine-compare.mjs';
import { backedLine, isKnownEntry, reviewThreatModel } from './threat-model.mjs';
import { getRule } from './rule-catalogue.mjs';
import { isMain } from '../util/is-main.mjs';
const SEVERITIES = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'];
@ -243,6 +257,60 @@ function passEngine(payload, ctx) {
return { headEdit: (head) => withVerdict(head, r.raisedVerdict) };
}
// ---- pass: outside -----------------------------------------------------------------
const OUTSIDE_RE = /^##\s+Outside the Threat Model\s*$/;
/** The `## Outside the Threat Model` section: the entries that stay, and how many went back. */
function outsideSection(kept, back) {
const note = back > 0 ? `Put back into the findings, the brief does not back them now: ${back}.\n\n` : '';
if (kept.length === 0) return `## Outside the Threat Model\n\n${note}None.\n\n`;
const items = kept.map((e) => (isKnownEntry(e)
? `- ${oneLine(e.title) || '(no title)'} (\`${oneLine(e.file)}:${e.line}\`, \`${oneLine(e.rule_key)}\`) — Outside: ${oneLine(e.threat_model_ref)}`
: '- (a malformed entry: the verdict gate cannot check it against the brief)'));
return '## Outside the Threat Model\n\n'
+ `Voyage's own security findings the brief's threat model puts outside, listed as known issues; they do not count: ${kept.length}.\n\n`
+ `${note}${items.join('\n')}\n\n`;
}
/**
* Pass `outside`, right after engine: each `known_outside` entry the brief does
* not back now goes back into the findings at its catalogue tier.
*/
function passOutside(payload, ctx) {
if (!Array.isArray(payload.known_outside) || payload.known_outside.length === 0) return {};
const use = reviewThreatModel(ctx.text, ctx.repo);
const tm = use.usable ? use.threatModel : null;
const kept = [];
const back = [];
for (const e of payload.known_outside) {
if (isKnownEntry(e) && !(tm && backedLine(e, tm) !== null)) back.push(e);
else kept.push(e);
}
const why = tm ? 'no `Outside:` line of the brief equals its threat_model_ref, or it is not a Voyage security finding' : use.why;
for (const e of back) {
const rule = getRule(e.rule_key.trim().toUpperCase());
const f = { ...e, id: null, rule_key: rule ? rule.rule_key : e.rule_key, severity: rule ? rule.severity : 'BLOCKER' };
payload.findings.push(f);
ctx.added.add(f);
ctx.outsideBack.set(f, why);
}
payload.known_outside = kept;
ctx.outside = { kept: kept.length, put_back: back.length };
const section = outsideSection(kept, back.length);
const edits = [(head) => replaceSection(head, OUTSIDE_RE, section,
[/^##\s+Pre-existing\s*$/, /^##\s+Resolved since round\b/, /^##\s+Declined to Judge\b/])];
if (back.length > 0) {
ctx.recount = true;
const v = raisedVerdict(payload.verdict, payload.findings);
if (v !== payload.verdict) {
payload.verdict = v;
edits.push((head) => withVerdict(head, v));
}
}
return { headEdit: (head) => edits.reduce((h, fn) => fn(h), head) };
}
// ---- pass: instruction -------------------------------------------------------------
const INSTRUCTION_KEYS = new Set(['CLAUDE_MD_DEVIATION', 'CLAUDE_MD_STALE']);
@ -333,7 +401,7 @@ function passInstruction(payload, ctx) {
// ---- pass: carry -------------------------------------------------------------------
const LATER_SECTIONS = [/^##\s+Suppressed Findings\b/, /^##\s+Pre-existing\s*$/, /^##\s+Resolved since round\b/,
const LATER_SECTIONS = [/^##\s+Suppressed Findings\b/, /^##\s+Outside the Threat Model\s*$/, /^##\s+Pre-existing\s*$/, /^##\s+Resolved since round\b/,
/^##\s+Declined to Judge\b/, /^##\s+Remediation Steps\b/, /^##\s+Remediation Summary\b/, /^##\s+Answers\s*$/];
/** The `## Resolved since round <n>` section. */
@ -531,6 +599,7 @@ function passPolicy(payload, ctx) {
/** The passes, in the Architecture order; the id recomputation runs after them, always last. */
export const PASSES = Object.freeze([
{ name: 'engine', run: passEngine },
{ name: 'outside', run: passOutside },
{ name: 'instruction', run: passInstruction },
{ name: 'carry', run: passCarry },
{ name: 'pre-existing', run: passPreExisting },
@ -589,13 +658,17 @@ function editFrontmatter(head, ids, fields) {
/**
* A body entry for a finding finalize added, in the coordinator's form; an
* engine put-back names its source first.
* engine put-back, or a known issue put back from outside the threat model,
* names its source first.
*/
function entryLines(f, putBackSet) {
function entryLines(f, putBackSet, outsideBack) {
const lines = [`### ${f.id}`, ''];
if (putBackSet.has(f)) lines.push(`**Source:** engine ${oneLine(f.engine)}, put back by the standard-path comparison`, '');
if (outsideBack.has(f)) {
lines.push(`**Source:** listed outside the threat model (Outside: ${oneLine(f.threat_model_ref)}), put back: ${oneLine(outsideBack.get(f))}`, '');
}
lines.push(`- file: ${oneLine(f.file)}`, `- line: ${f.line}`, `- rule_key: ${oneLine(f.rule_key)}`);
for (const k of ['brief_ref', 'title', 'detail', 'recommended_action', 'engine', 'carried_from']) {
for (const k of ['brief_ref', 'title', 'detail', 'recommended_action', 'engine', 'carried_from', 'threat_model_ref']) {
if (f[k] !== undefined && f[k] !== null && oneLine(f[k]) !== '') lines.push(`- ${k}: ${oneLine(f[k])}`);
}
return lines;
@ -612,14 +685,14 @@ function findingsBefore(sev) {
* The body entries: one added for each finding in `added`, and a `### <id>`
* entry that sits under another severity's `## Findings` section moved to its own.
*/
function placeEntries(head, findings, added, putBackSet) {
function placeEntries(head, findings, added, putBackSet, outsideBack = new Map()) {
let lines = head.split('\n');
const fmEnd = frontmatterEnd(lines);
for (const f of findings) {
const sev = sevOf(f);
if (!SEVERITIES.includes(sev) || typeof f.id !== 'string') continue;
if (added.has(f)) {
lines = appendToSection(lines, findingsHeading(sev), `## Findings (${sev})`, entryLines(f, putBackSet), findingsBefore(sev));
lines = appendToSection(lines, findingsHeading(sev), `## Findings (${sev})`, entryLines(f, putBackSet, outsideBack), findingsBefore(sev));
continue;
}
const heading = new RegExp(`^###\\s+${f.id}\\s*$`);
@ -727,7 +800,7 @@ export function finalizeReview(text, { repo, round, roundBase = null, previous =
if (dup !== undefined) return { exit: 1, reason: `two findings carry the id ${JSON.stringify(dup)}` };
const ev = evidence ?? { ran: { engines: [], reviewers: [] }, marks: [], payloads: [] };
const ctx = { repo, text, round, previous, evidence: ev, added: new Set(), putBack: new Set(), carried: false };
const ctx = { repo, text, round, previous, evidence: ev, added: new Set(), putBack: new Set(), outsideBack: new Map(), carried: false };
let head = parts.head;
const out = { passes: [] };
for (const pass of PASSES) {
@ -750,7 +823,7 @@ export function finalizeReview(text, { repo, round, roundBase = null, previous =
head = rewriteIds(head, map);
out.passes.push('ids');
head = placeEntries(head, payload.findings, ctx.added, ctx.putBack);
head = placeEntries(head, payload.findings, ctx.added, ctx.putBack, ctx.outsideBack);
if (ctx.carried || ctx.recount) {
payload.counts = Object.fromEntries(SEVERITIES.map((s) => [s, payload.findings.filter((f) => sevOf(f) === s).length]));
}
@ -778,6 +851,7 @@ export function finalizeReview(text, { repo, round, roundBase = null, previous =
exit: 0, text: `${head}${parts.fence}${body}${parts.tail}`, ...out, ids_rewritten: map.size,
carried: ctx.added.size, resolved: payload.resolved ?? [], ran: payload.ran, r_steps_added: steps.added,
policy: ctx.policy ?? null, put_back: ctx.engine?.put_back ?? 0, instruction: ctx.instruction ?? null,
outside: ctx.outside ?? null,
};
}

View file

@ -21,12 +21,19 @@
// 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';
@ -85,6 +92,31 @@ export function usable(briefPath, repo) {
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;

View file

@ -51,6 +51,14 @@
// An ALLOW/WARN review that holds one finding id twice, in the
// frontmatter `findings:` list or in the trailing JSON block, is exit
// 2 (reason `duplicate finding id <id> in <where>`).
// An ALLOW/WARN review whose optional `known_outside` (E 2.5: Voyage's
// own security findings the brief's threat model puts outside) holds
// an entry the brief at its `brief_path` does not back now (its
// `Outside:` line gone, the threat model not usable, an engine
// finding, not a security finding, a malformed entry or list) is
// exit 2 too (reason `review-not-measured: known issue outside the
// threat model not backed by the brief (<why>): …`;
// `notBackedOutside`): the brief is re-read on every run.
// Exit 2 is NEVER green.
//
// The pure `decideVerdict` stays text-only: it never reads the answers. The
@ -85,6 +93,7 @@ import { frontmatterField, runId } from './review-rounds.mjs';
// No cycle: neither module, nor any module they import, imports this gate.
import { roundEvidence } from './review-finalize.mjs';
import { putBack, normTitle } from './engine-compare.mjs';
import { backedLine, isKnownEntry, reviewThreatModel } from './threat-model.mjs';
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
@ -220,6 +229,7 @@ function blockerSectionEntries(body) {
const NOT_FINALIZED = 'review-not-measured: round 2+ review not finalized';
const MOVED_SERIOUS = 'a policy or filter moved a BLOCKER, MAJOR or security finding';
const DROPPED_ENGINE = 'review-not-measured: engine finding not in review';
const NOT_BACKED = 'review-not-measured: known issue outside the threat model not backed by the brief';
const NOTED_SEVERITIES = new Set(['MINOR', 'SUGGESTION']);
/** One field of a declined entry for the reason line: one line, at most 120 chars. */
@ -307,6 +317,29 @@ export function droppedEngineFindings(text, reviewPath) {
return putBack(review, payloads).added.filter((e) => !carried.some((c) => samePlace(c, e)));
}
/**
* The `known_outside` entries of review.md the brief at its `brief_path` does
* not back now (lib/review/threat-model.mjs `reviewThreatModel`, `backedLine`):
* a malformed list or entry, an engine finding, not a security finding, an
* `Outside:` line gone, or a threat model that is not usable. An unreadable
* trailing block gives none (decideVerdict judges it).
* @param {string} text - review.md
* @param {string|undefined} repo - the repo's top level
* @returns {{entries: Array<*>, why: string|null}}
*/
export function notBackedOutside(text, repo) {
const payload = trailingPayload(String(text ?? ''));
if (payload === null || typeof payload !== 'object' || !('known_outside' in payload)) return { entries: [], why: null };
const list = payload.known_outside;
if (!Array.isArray(list)) return { entries: [list], why: 'known_outside is not a list' };
if (list.length === 0) return { entries: [], why: null };
const use = reviewThreatModel(text, repo);
const tm = use.usable ? use.threatModel : null;
const entries = list.filter((e) => !isKnownEntry(e) || !(tm && backedLine(e, tm) !== null));
const why = tm ? 'no `Outside:` line of the brief equals its threat_model_ref, it is not a Voyage security finding, or it is malformed' : use.why;
return { entries, why: entries.length > 0 ? why : null };
}
/**
* The open-findings check: every counting MAJOR of an ALLOW/WARN review must be
* answered (lib/review/review-answers.mjs `decideAnswers`). K2 records come
@ -322,6 +355,7 @@ export function droppedEngineFindings(text, reviewPath) {
* pre-existing: it keeps its line from the previous round's end. A round 2+
* review that was not finalized (`unfinalizedRound`) is exit 2 first, then an
* engine MAJOR or BLOCKER missing from the review (`droppedEngineFindings`),
* then a `known_outside` entry the brief does not back (`notBackedOutside`),
* then a BLOCKER, MAJOR or security finding moved by a policy or filter (`movedSerious`).
* @param {string} text - review.md
* @param {{ repo?: string, reviewPath: string }} opts
@ -339,6 +373,18 @@ export function decideOpenFindings(text, { repo, reviewPath } = {}) {
fixed_pending: [],
};
}
const outside = notBackedOutside(text, repo);
if (outside.entries.length > 0) {
const what = (e) => (e !== null && typeof e === 'object'
? `${field(e.title) ?? '(no title)'} at ${field(e.file) ?? '?'}:${field(e.line) ?? '?'}`
: JSON.stringify(e));
return {
exit: EXIT.UNDECIDED,
reason: `${NOT_BACKED} (${outside.why}): ${outside.entries.map(what).join(', ')}`,
unanswered: [],
fixed_pending: [],
};
}
const hidden = movedSerious(text);
if (hidden.length > 0) {
return {

View file

@ -22,6 +22,7 @@ import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
import { extractFindingsBlock } from '../review/findings-schema.mjs';
import { isKnownEntry } from '../review/threat-model.mjs';
export const REVIEW_REQUIRED_FRONTMATTER = [
'type',
@ -76,8 +77,10 @@ export function remediationStepSources(body) {
// `fixed` without its R-step), REVIEW_POLICY_MALFORMED (`review_policy`,
// `review_policy_sha256`, a finding's `instruction_ref`), REVIEW_ROUND_MALFORMED
// (`round`, `round_base`, `finalized`, `resolved`, `ran`, a finding's
// `carried_from`) and REVIEW_PRE_EXISTING_MALFORMED (a finding's `pre_existing`,
// `pre_existing_counts`).
// `carried_from`), REVIEW_PRE_EXISTING_MALFORMED (a finding's `pre_existing`,
// `pre_existing_counts`) and REVIEW_KNOWN_OUTSIDE_MALFORMED (the optional
// `known_outside` list, E 2.5: Voyage's own security findings the brief's
// threat model puts outside; lib/review/threat-model.mjs `isKnownEntry`).
const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
const SHA256_RE = /^[0-9a-f]{64}$/;
@ -163,6 +166,7 @@ function review12Issues(fm, body) {
const policy = (msg) => out.push(issue('REVIEW_POLICY_MALFORMED', msg));
const pre = (msg) => out.push(issue('REVIEW_PRE_EXISTING_MALFORMED', msg));
const answers = (msg) => out.push(issue('REVIEW_ANSWERS_MALFORMED', msg));
const outside = (msg) => out.push(issue('REVIEW_KNOWN_OUTSIDE_MALFORMED', msg));
// Frontmatter.
if ('round' in fm && !(Number.isInteger(fm.round) && fm.round >= 1 && fm.round <= 3)) {
@ -211,6 +215,17 @@ function review12Issues(fm, body) {
round(`resolved must be an array of 40-char hex finding ids, got ${JSON.stringify(r)}`);
}
}
if ('known_outside' in payload) {
const k = payload.known_outside;
if (!Array.isArray(k)) outside(`known_outside must be an array, got ${JSON.stringify(k)}`);
else {
k.forEach((e, i) => {
if (!isKnownEntry(e)) {
outside(`known_outside[${i}] needs a string rule_key and file, an integer line >= 0 and a non-empty threat_model_ref, got ${JSON.stringify(e)}`);
}
});
}
}
if ('ran' in payload) {
const r = payload.ran;
const okRan = isPlainObject(r)