feat(gate): the brief's threat model on the plan side (decision 164, 218, 220)
plan-gate.mjs --record --brief <brief.md> (a known flag; its value is kept
out of the positional arguments, a missing value is a usage error) applies
the brief's threat model to the deduped findings before countRound when
threat-model.mjs usable() says it may: a backed finding (Voyage's own
security finding whose threat_model_ref equals an Outside: line's text)
leaves the counted set and the stop rule and goes to the new optional
verdict field known_outside ({id, rule_key, text, line}), carried to later
rounds until a reviewer marks it resolved or it is reported again under its
id. The verdict stores brief_path (carried when a later round has none).
The --record line adds known_outside (a count) and threat_model
({applied, why}): a threat model that is not usable backs nothing, every
security finding counts, and the output says why. extractVerdict
shape-checks both new fields.
The bare gate re-reads the brief at brief_path each run: an entry still
backed is a warning, PLAN_GATE_KNOWN_OUTSIDE; one no longer backed (line
gone, approval stale or not logged, brief unreadable, no brief_path, or not
a Voyage security finding) stops the plan, PLAN_GATE_OUTSIDE_NOT_BACKED: it
counts in full. gatePlan takes an optional threatModel for tests.
plan-review-dedup keeps threat_model_ref on a merge only when every merged
copy carries the same one; the S160 known issue is fixed: a null in a
reviewer's findings list is skipped instead of crashing the CLI.
plan-critic and scope-guardian: a security finding needs a concrete attack
path and high confidence (Claude Code's threshold, 167), uses
SECURITY_INJECTION or SECURITY_RISK, and quotes the Outside: line's text in
threat_model_ref when one applies; the json template carries the field.
/trekplan Phase 9 passes the brief path to plan-critic and --brief to
--record. Handover 4 names the two fields, the two codes and the flag.
Part 2, step 2.4 of the E plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5cb2918baa
commit
98171d7d3b
10 changed files with 612 additions and 26 deletions
|
|
@ -13,7 +13,7 @@
|
|||
// Prints the sha256 of the plan's operative text. Phase 9 takes it BEFORE
|
||||
// the reviewers read the plan and hands it to --record.
|
||||
//
|
||||
// node plan-gate.mjs --record <plan.md> --reviewed <sha256> [--extra-round]
|
||||
// node plan-gate.mjs --record <plan.md> --reviewed <sha256> [--extra-round] [--brief <brief.md>]
|
||||
// stdin = plan-review-dedup.mjs output. Writes this round's verdict into
|
||||
// the plan. 0 = written; 1 = refused: the previous verdict's stop rule
|
||||
// fired (`PLAN_GATE_STOP_RULE`, below; `--extra-round` cannot open it) or
|
||||
|
|
@ -29,8 +29,11 @@
|
|||
// before the plan is written. Output: one JSON line, which adds
|
||||
// `diff_base`, `diff_note`, `lowered` (a number) and `snapshot` ("kept: ..."
|
||||
// or "not kept: <reason>"; a failed snapshot write does not fail the round),
|
||||
// `stop_rule` (this round's decision, or on a refusal the one that fired)
|
||||
// and `reason` (`PLAN_GATE_STOP_RULE` on that refusal, else null).
|
||||
// `stop_rule` (this round's decision, or on a refusal the one that fired),
|
||||
// `reason` (`PLAN_GATE_STOP_RULE` on that refusal, else null),
|
||||
// `known_outside` (how many findings the threat model lists as known
|
||||
// issues) and `threat_model` ({applied, why}: whether the brief's threat
|
||||
// model was applied, and why not; below).
|
||||
//
|
||||
// node plan-gate.mjs --diff <plan.md>
|
||||
// Prints what the next critic round reviews: `diff-base: <sha256> (round N)`
|
||||
|
|
@ -111,6 +114,24 @@
|
|||
// operator accepts the open findings (--accept-open) or the direction
|
||||
// changes. A verdict without `rounds` starts its history at the next round,
|
||||
// so an older block never fires on its first new round.
|
||||
//
|
||||
// The brief's threat model (E 2.4, decisions 164, 218, 220;
|
||||
// lib/review/threat-model.mjs): `--record --brief <brief.md>` applies it to
|
||||
// the deduped findings before countRound, when it is usable (present, well
|
||||
// formed, and the intent approval current by the marker and the approval
|
||||
// log). A backed finding — Voyage's own security finding (no engine) whose
|
||||
// `threat_model_ref` equals an `Outside:` line's text exactly — leaves the
|
||||
// counted set and the stop rule and goes to the optional verdict field
|
||||
// `known_outside` ({id, rule_key, text, line}), carried to later rounds until
|
||||
// a reviewer marks it resolved (or it counts again under its id); the verdict
|
||||
// stores `brief_path` (carried when a later round has no --brief). A threat
|
||||
// model that is not usable backs nothing: every security finding counts, and
|
||||
// `threat_model.why` says why. The bare gate re-reads the brief at
|
||||
// `brief_path` on every run: an entry still backed is a warning
|
||||
// (PLAN_GATE_KNOWN_OUTSIDE); one no longer backed (its line gone, the
|
||||
// approval stale, the brief unreadable, no brief_path) stops the plan
|
||||
// (PLAN_GATE_OUTSIDE_NOT_BACKED): it counts in full, and a security finding
|
||||
// is never accepted.
|
||||
|
||||
import {
|
||||
readFileSync, existsSync, writeFileSync, renameSync, rmSync, statSync, mkdirSync, realpathSync,
|
||||
|
|
@ -122,6 +143,7 @@ import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from
|
|||
import { normalizeSeverity, isSecurity } from '../review/plan-review-dedup.mjs';
|
||||
import { countRound, lineDiff, oneLine } from '../review/critic-diff.mjs';
|
||||
import { roundSummary, decideStop, describeStop, STOP_REASONS } from '../review/stop-rule.mjs';
|
||||
import { usable as threatModelUsable, backedLine } from '../review/threat-model.mjs';
|
||||
import { resolveRepo } from '../verification/repo-tools.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
|
|
@ -232,9 +254,9 @@ export function extractVerdict(text) {
|
|||
Number.isInteger(v.extra_rounds ?? 0) && (v.extra_rounds ?? 0) >= 0 &&
|
||||
typeof v.plan_sha256 === 'string' && Array.isArray(v.findings) &&
|
||||
v.findings.every(f => f && typeof f === 'object' && typeof f.severity === 'string') &&
|
||||
okRounds(v.rounds) && okStopRule(v.stop_rule);
|
||||
okRounds(v.rounds) && okStopRule(v.stop_rule) && okKnownOutside(v.known_outside) && okBriefPath(v.brief_path);
|
||||
if (!okShape) {
|
||||
return { ...none, found: true, error: `verdict block is not ${VERDICT_SCHEMA} (schema, round, max_rounds <= ${MAX_ROUNDS}, extra_rounds, plan_sha256, findings with severity, optional rounds and stop_rule)` };
|
||||
return { ...none, found: true, error: `verdict block is not ${VERDICT_SCHEMA} (schema, round, max_rounds <= ${MAX_ROUNDS}, extra_rounds, plan_sha256, findings with severity, optional rounds, stop_rule, known_outside and brief_path)` };
|
||||
}
|
||||
return { found: true, placeholder: false, verdict: v, error: null };
|
||||
}
|
||||
|
|
@ -257,6 +279,24 @@ function okStopRule(s) {
|
|||
Array.isArray(s.classes) && s.classes.every(c => typeof c === 'string') && okRounds(s.perRound);
|
||||
}
|
||||
|
||||
const isStrOrNull = x => x === null || typeof x === 'string';
|
||||
|
||||
/** Optional `known_outside`: a list of {id: string|null, rule_key: string|null, text: string, line: non-empty string}. */
|
||||
function okKnownOutside(k) {
|
||||
if (k === undefined) return true;
|
||||
return Array.isArray(k) && k.every(e => isPlainObject(e) && isStrOrNull(e.id) && isStrOrNull(e.rule_key) &&
|
||||
typeof e.text === 'string' && typeof e.line === 'string' && e.line !== '');
|
||||
}
|
||||
|
||||
/** Optional `brief_path`: a non-empty string. */
|
||||
const okBriefPath = p => p === undefined || (typeof p === 'string' && p !== '');
|
||||
|
||||
/** A previous known_outside entry leaves on a reviewer's resolved: true, and nobody's false. */
|
||||
function knownResolved(e, marks) {
|
||||
const ms = marks.filter(m => m && typeof m.id === 'string' && m.id === e.id);
|
||||
return ms.some(m => m.resolved === true) && !ms.some(m => m.resolved === false);
|
||||
}
|
||||
|
||||
function verdictFor(counts) {
|
||||
if (counts.blocker > 0) return 'BLOCK';
|
||||
if (counts.major > 0) return 'REVISE';
|
||||
|
|
@ -302,14 +342,18 @@ function missingReviewers(dedup) {
|
|||
* @param {{findings: Array<object>, sources: Array<{agent: string, present: boolean}>}} dedup
|
||||
* plan-review-dedup.mjs output
|
||||
* @param {{reviewed?: string, extraRound?: boolean, maxRounds?: number, now?: string,
|
||||
* base?: {sha256: string, text: string}|null, baseNote?: string, planPaths?: string[]}} [opts]
|
||||
* base?: {sha256: string, text: string}|null, baseNote?: string, planPaths?: string[],
|
||||
* threatModel?: {usable: boolean, why: string|null, threatModel: object}, briefPath?: string}} [opts]
|
||||
* reviewed = planHash of the text the reviewers read (from --hash);
|
||||
* base = the operative text the previous round reviewed, with its sha256;
|
||||
* baseNote = why there is no base (one line, written as diff_note);
|
||||
* planPaths = the absolute forms of the plan path (a finding whose
|
||||
* resolve(file) is one of them cites the plan). Without base, baseNote and
|
||||
* planPaths the round is recorded as before, plus the rule-B record.
|
||||
* @returns {{code: 0|1|2, text?: string, verdict?: object, lowered?: number, message: string}}
|
||||
* threatModel = lib/review/threat-model.mjs `usable(brief)` for --brief;
|
||||
* briefPath = the --brief path, stored as `brief_path`.
|
||||
* @returns {{code: 0|1|2, text?: string, verdict?: object, lowered?: number,
|
||||
* threat_model?: {applied: boolean, why: string|null}, message: string}}
|
||||
*/
|
||||
export function recordVerdict(text, dedup, opts = {}) {
|
||||
if (!dedup || typeof dedup !== 'object' || !Array.isArray(dedup.findings)) {
|
||||
|
|
@ -355,6 +399,17 @@ export function recordVerdict(text, dedup, opts = {}) {
|
|||
}
|
||||
const extra = prevExtra + (capped ? 1 : 0);
|
||||
const diffMode = opts.base !== undefined || opts.baseNote !== undefined || opts.planPaths !== undefined;
|
||||
// The threat model (E 2.4): a backed finding leaves the counted set (and so
|
||||
// the stop rule) before countRound and is listed in known_outside.
|
||||
const use = opts.threatModel;
|
||||
const tm = use && use.usable === true ? use.threatModel : null;
|
||||
const backed = [];
|
||||
const counted = [];
|
||||
for (const f of dedup.findings) {
|
||||
const line = tm ? backedLine(f, tm) : null;
|
||||
if (line !== null) backed.push({ id: f.id ?? null, rule_key: f.rule_key ?? null, text: typeof f.text === 'string' ? f.text : '', line });
|
||||
else counted.push(f);
|
||||
}
|
||||
const c = countRound({
|
||||
round,
|
||||
previous: prev.verdict ?? null,
|
||||
|
|
@ -362,7 +417,7 @@ export function recordVerdict(text, dedup, opts = {}) {
|
|||
baseNote: opts.baseNote ?? null,
|
||||
current: { text: lines.text, sha256: current, fullToOp: lines.fullToOp, opToFull: lines.opToFull },
|
||||
planPaths: Array.isArray(opts.planPaths) ? opts.planPaths : [],
|
||||
findings: dedup.findings,
|
||||
findings: counted,
|
||||
marks: Array.isArray(dedup.marks) ? dedup.marks : [],
|
||||
});
|
||||
const findings = c.findings;
|
||||
|
|
@ -382,15 +437,32 @@ export function recordVerdict(text, dedup, opts = {}) {
|
|||
verdict.counts = counts;
|
||||
verdict.findings = findings;
|
||||
if (c.resolved !== null) verdict.resolved = c.resolved;
|
||||
// known_outside: this round's backed findings, after the previous round's
|
||||
// entries that no reviewer marked resolved and that are not reported again
|
||||
// under their id (backed again: listed once; counted: in full, not listed).
|
||||
const marks = Array.isArray(dedup.marks) ? dedup.marks : [];
|
||||
const backedIds = new Set(backed.map(e => e.id).filter(id => id !== null));
|
||||
const countedIds = new Set(findings.map(x => x.id).filter(id => typeof id === 'string'));
|
||||
const kept = (Array.isArray(prev.verdict?.known_outside) ? prev.verdict.known_outside : [])
|
||||
.filter(e => !knownResolved(e, marks) && !backedIds.has(e.id) && !countedIds.has(e.id));
|
||||
const knownOutside = [...kept, ...backed];
|
||||
if (knownOutside.length > 0) verdict.known_outside = knownOutside;
|
||||
const briefPath = typeof opts.briefPath === 'string' && opts.briefPath !== '' ? opts.briefPath : prev.verdict?.brief_path;
|
||||
if (briefPath !== undefined) verdict.brief_path = briefPath;
|
||||
// The stop rule's history: this round's summary after the carried ones. A
|
||||
// round with a diff_note (or, outside diff mode, any round from 2 on) was
|
||||
// counted in full, so "new" is not known: newByClass null.
|
||||
const history = Array.isArray(prev.verdict?.rounds) ? prev.verdict.rounds : [];
|
||||
verdict.rounds = [...history, { round, ...roundSummary(findings, { fullCount: c.diffNote !== null }) }];
|
||||
verdict.stop_rule = decideStop(verdict.rounds);
|
||||
const threat = use === undefined
|
||||
? { applied: false, why: 'no --brief given' }
|
||||
: { applied: use.usable === true, why: use.usable === true ? null : (use.why ?? 'the threat model is not usable') };
|
||||
return {
|
||||
code: 0, text: placeSection(text, renderSection(verdict)), verdict, lowered: c.lowered,
|
||||
code: 0, text: placeSection(text, renderSection(verdict)), verdict, lowered: c.lowered, threat_model: threat,
|
||||
message: `round ${round} recorded: ${verdict.verdict}` +
|
||||
(knownOutside.length > 0 ? `; ${knownOutside.length} finding(s) outside the threat model listed as known issues` : '') +
|
||||
(use !== undefined && !threat.applied ? `; threat model not applied, every security finding counts: ${threat.why}` : '') +
|
||||
(verdict.stop_rule.fired ? `; PLAN_GATE_STOP_RULE: ${describeStop(verdict.stop_rule)}` : ''),
|
||||
};
|
||||
}
|
||||
|
|
@ -465,11 +537,44 @@ export function acceptOpen(rawText, opts = {}) {
|
|||
return { code: 0, text: placeSection(text, renderSection(accepted)), message: `${majors.length} open MAJOR(s) of round ${v.round} accepted` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check each known_outside entry against the brief (E 2.4): backed -> a
|
||||
* warning; not backed -> a stop reason (it counts in full).
|
||||
* @param {object[]} entries - the verdict's known_outside
|
||||
* @param {{usable: boolean, why: string|null, threatModel: object}} use - threat-model.mjs `usable`
|
||||
*/
|
||||
function checkKnownOutside(entries, use, reasons, warnings) {
|
||||
const tm = use?.usable === true ? use.threatModel : null;
|
||||
const backed = [];
|
||||
const unbacked = [];
|
||||
for (const e of entries) {
|
||||
(tm && backedLine({ rule_key: e.rule_key, threat_model_ref: e.line }, tm) !== null ? backed : unbacked).push(e);
|
||||
}
|
||||
if (backed.length > 0) {
|
||||
warnings.push({
|
||||
code: 'PLAN_GATE_KNOWN_OUTSIDE',
|
||||
message: `${backed.length} security finding(s) outside the brief's threat model, listed as known issues: ${backed.map(e => `${e.text} (Outside: ${e.line})`).join(' | ')}`,
|
||||
});
|
||||
}
|
||||
if (unbacked.length > 0) {
|
||||
const why = tm ? 'no `Outside:` line in the brief equals it, or it is not a Voyage security finding' : (use?.why ?? 'the threat model is not usable');
|
||||
reasons.push({
|
||||
code: 'PLAN_GATE_OUTSIDE_NOT_BACKED',
|
||||
message: `${unbacked.length} finding(s) recorded as outside the threat model are not backed by the brief now (${why}), so they count in full: ${unbacked.map(e => e.text).join(' | ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* K3 — decide whether the plan may go on.
|
||||
* @param {string} rawText - the plan
|
||||
* @param {{threatModel?: {usable: boolean, why: string|null, threatModel: object}}} [opts]
|
||||
* threatModel = the brief's threat model as threat-model.mjs `usable` gives it;
|
||||
* absent, the gate reads the brief at the verdict's `brief_path` itself
|
||||
* (only when the verdict lists `known_outside`).
|
||||
* @returns {{code: 0|1, status: 'pass'|'stop', reasons: object[], warnings: object[], verdict: object|null, stop_rule: object|null, validation: object}}
|
||||
*/
|
||||
export function gatePlan(rawText) {
|
||||
export function gatePlan(rawText, opts = {}) {
|
||||
const text = norm(rawText);
|
||||
const reasons = [];
|
||||
const warnings = [];
|
||||
|
|
@ -524,6 +629,13 @@ export function gatePlan(rawText) {
|
|||
reasons.push({ code: 'PLAN_GATE_ROUNDS_EXHAUSTED', message: `All ${v.round} critic rounds are spent with a BLOCKER open: stop and ask the operator.` });
|
||||
}
|
||||
}
|
||||
const known = Array.isArray(v.known_outside) ? v.known_outside : [];
|
||||
if (known.length > 0) {
|
||||
const use = opts.threatModel ?? (typeof v.brief_path === 'string'
|
||||
? threatModelUsable(v.brief_path)
|
||||
: { usable: false, why: 'the verdict names no brief_path', threatModel: null });
|
||||
checkKnownOutside(known, use, reasons, warnings);
|
||||
}
|
||||
if (!stop && majors.length > 0) {
|
||||
const exhausted = v.round >= v.max_rounds + (v.extra_rounds ?? 0);
|
||||
const list = majors.map(m => m.text).join(' | ');
|
||||
|
|
@ -656,14 +768,19 @@ function diffReport(text, dir) {
|
|||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --diff <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json | --accept-open <plan.md>';
|
||||
const known = new Set(['--json', '--hash', '--diff', '--record', '--reviewed', '--extra-round', '--accept-open']);
|
||||
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --diff <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] [--brief <brief.md>] < dedup.json | --accept-open <plan.md>';
|
||||
const known = new Set(['--json', '--hash', '--diff', '--record', '--reviewed', '--extra-round', '--accept-open', '--brief']);
|
||||
const rIdx = args.indexOf('--reviewed');
|
||||
const reviewed = rIdx >= 0 ? args[rIdx + 1] : undefined;
|
||||
const positional = args.filter((a, i) => !a.startsWith('--') && !(rIdx >= 0 && i === rIdx + 1));
|
||||
// --brief takes a value, kept out of the positional arguments like --reviewed's.
|
||||
const bIdx = args.indexOf('--brief');
|
||||
const briefArg = bIdx >= 0 ? args[bIdx + 1] : undefined;
|
||||
const valueAt = i => (rIdx >= 0 && i === rIdx + 1) || (bIdx >= 0 && i === bIdx + 1);
|
||||
const positional = args.filter((a, i) => !a.startsWith('--') && !valueAt(i));
|
||||
const unknown = args.filter(a => a.startsWith('--') && !known.has(a));
|
||||
const planPath = positional[0];
|
||||
if (!planPath || positional.length > 1 || unknown.length > 0 || (rIdx >= 0 && (!reviewed || reviewed.startsWith('--')))) {
|
||||
if (!planPath || positional.length > 1 || unknown.length > 0 || (rIdx >= 0 && (!reviewed || reviewed.startsWith('--'))) ||
|
||||
(bIdx >= 0 && (!briefArg || briefArg.startsWith('--')))) {
|
||||
process.stderr.write(`${usage}\n`);
|
||||
out({ code: 2, status: 'error', message: unknown.length ? `unknown argument: ${unknown.join(' ')}` : 'bad arguments' });
|
||||
process.exit(2);
|
||||
|
|
@ -708,6 +825,10 @@ if (isMain(import.meta.url)) {
|
|||
}
|
||||
const dir = snapshotDir(planPath);
|
||||
const opts = { extraRound: args.includes('--extra-round'), reviewed, planPaths: planPathsOf(planPath) };
|
||||
if (briefArg !== undefined) {
|
||||
opts.briefPath = briefArg;
|
||||
opts.threatModel = threatModelUsable(briefArg);
|
||||
}
|
||||
const lb = loadBase(text, dir);
|
||||
if (lb.verdict) {
|
||||
opts.base = lb.base;
|
||||
|
|
@ -735,6 +856,7 @@ if (isMain(import.meta.url)) {
|
|||
diff_base: r.verdict?.diff_base ?? null, diff_note: r.verdict?.diff_note ?? null,
|
||||
lowered: r.code === 0 ? r.lowered : null, snapshot,
|
||||
stop_rule: r.verdict?.stop_rule ?? r.stop_rule ?? null, reason: r.reason ?? null,
|
||||
known_outside: r.verdict?.known_outside?.length ?? 0, threat_model: r.threat_model ?? null,
|
||||
});
|
||||
process.exit(r.code);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue