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>
201 lines
9.5 KiB
JavaScript
201 lines
9.5 KiB
JavaScript
// tests/lib/threat-model.test.mjs
|
|
// E 2.2 — one reader of the brief's `## Threat Model` for both gates
|
|
// (decisions 164, 218): readThreatModel reads the section; usable says whether
|
|
// it may be applied (present, and the intent approval current: the marker
|
|
// check and the approval log, the two checks /trekplan Phase 1 runs), and why
|
|
// not; backedLine finds the `Outside:` line a finding quotes exactly. Only a
|
|
// security finding not from an engine can be outside.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { OUTSIDE, INSIDE, TM, briefWith } from '../helpers/threat-model-brief.mjs';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(HERE, '..', '..');
|
|
const load = () => import(join(ROOT, 'lib', 'review', 'threat-model.mjs'));
|
|
const approval = () => import(join(ROOT, 'lib', 'validators', 'intent-approval.mjs'));
|
|
const log = () => import(join(ROOT, 'lib', 'util', 'approval-log.mjs'));
|
|
|
|
const brief = (threat = TM, type = 'trekbrief') => briefWith(threat, type);
|
|
|
|
const own = (extra = {}) => ({ id: 'f1', rule_key: 'SECURITY_INJECTION', severity: 'blocker', text: 'shell built from a name', threat_model_ref: OUTSIDE, ...extra });
|
|
|
|
// ---- readThreatModel ----
|
|
|
|
test('readThreatModel: present with its Inside and Outside texts; absent is not present', async () => {
|
|
const { readThreatModel } = await load();
|
|
const tm = readThreatModel(brief());
|
|
assert.equal(tm.present, true);
|
|
assert.deepEqual(tm.inside, [INSIDE]);
|
|
assert.deepEqual(tm.outside, [OUTSIDE]);
|
|
const none = readThreatModel(brief(''));
|
|
assert.equal(none.present, false);
|
|
assert.deepEqual(none.inside, []);
|
|
assert.deepEqual(none.outside, []);
|
|
});
|
|
|
|
test('readThreatModel: a malformed section is present with its problems; text without frontmatter still reads', async () => {
|
|
const { readThreatModel } = await load();
|
|
const bad = readThreatModel(brief('## Threat Model\n\n- Scope: all\n'));
|
|
assert.equal(bad.present, true);
|
|
assert.ok(bad.problems.length > 0, 'the problem is named');
|
|
const bare = readThreatModel(`# Brief\n\n${TM}`);
|
|
assert.deepEqual(bare.outside, [OUTSIDE]);
|
|
assert.deepEqual(readThreatModel(undefined).outside, []);
|
|
});
|
|
|
|
// ---- usable ----
|
|
|
|
async function approvedRepo(text) {
|
|
const { stampIntentApproval } = await approval();
|
|
const { appendApproval, normalizeHex, normalizeSubject, resolveRepo } = await log();
|
|
const dir = mkdtempSync(join(tmpdir(), 'threat-model-'));
|
|
const p = join(dir, 'brief.md');
|
|
const s = stampIntentApproval(text, new Date('2026-11-08T08:00:00Z'));
|
|
writeFileSync(p, s.stamped ? s.text : text);
|
|
const repo = resolveRepo(dir);
|
|
const logIt = (hash = s.hash) => appendApproval({ repo, kind: 'intent', subject: normalizeSubject(repo, p), sha: normalizeHex(hash) });
|
|
return { dir, p, s, logIt };
|
|
}
|
|
|
|
test('usable: present, stamped and logged → usable', async () => {
|
|
const { usable } = await load();
|
|
const { dir, p, logIt } = await approvedRepo(brief());
|
|
try {
|
|
assert.equal(logIt().appended, true);
|
|
const u = usable(p, dir);
|
|
assert.equal(u.usable, true, u.why);
|
|
assert.equal(u.why, null);
|
|
assert.deepEqual(u.threatModel.outside, [OUTSIDE]);
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('usable: no ## Threat Model → not usable, and it says why', async () => {
|
|
const { usable } = await load();
|
|
const { dir, p, logIt } = await approvedRepo(brief(''));
|
|
try {
|
|
logIt();
|
|
const u = usable(p, dir);
|
|
assert.equal(u.usable, false);
|
|
assert.match(u.why, /no ## Threat Model/);
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('usable: the threat model edited after the approval → not usable (stale)', async () => {
|
|
const { usable } = await load();
|
|
const { dir, p, s, logIt } = await approvedRepo(brief());
|
|
try {
|
|
logIt();
|
|
writeFileSync(p, s.text.replace(OUTSIDE, 'anyone at all'));
|
|
const u = usable(p, dir);
|
|
assert.equal(u.usable, false);
|
|
assert.match(u.why, /BRIEF_INTENT_APPROVAL_STALE/);
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('usable: stamped but not in the approval log → not usable (not-logged)', async () => {
|
|
const { usable } = await load();
|
|
const { dir, p } = await approvedRepo(brief());
|
|
try {
|
|
const u = usable(p, dir);
|
|
assert.equal(u.usable, false);
|
|
assert.match(u.why, /not-logged/);
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('usable: the log covers other intent text → not usable (mismatch)', async () => {
|
|
const { usable } = await load();
|
|
const { dir, p, logIt } = await approvedRepo(brief());
|
|
try {
|
|
logIt(`sha256:${'a'.repeat(64)}`);
|
|
const u = usable(p, dir);
|
|
assert.equal(u.usable, false);
|
|
assert.match(u.why, /mismatch/);
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('usable: unapproved, malformed, unreadable or a trekreview brief → not usable', async () => {
|
|
const { usable } = await load();
|
|
const dir = mkdtempSync(join(tmpdir(), 'threat-model-'));
|
|
try {
|
|
const p = join(dir, 'brief.md');
|
|
writeFileSync(p, brief());
|
|
let u = usable(p, dir);
|
|
assert.equal(u.usable, false);
|
|
assert.match(u.why, /BRIEF_INTENT_NOT_APPROVED/);
|
|
writeFileSync(p, brief('## Threat Model\n\n- Scope: all\n'));
|
|
u = usable(p, dir);
|
|
assert.equal(u.usable, false);
|
|
assert.match(u.why, /malformed/);
|
|
u = usable(join(dir, 'missing.md'), dir);
|
|
assert.equal(u.usable, false);
|
|
assert.match(u.why, /cannot read/);
|
|
writeFileSync(p, brief(TM, 'trekreview'));
|
|
u = usable(p, dir);
|
|
assert.equal(u.usable, false, 'a trekreview brief carries no approved intent of its own');
|
|
assert.equal(usable(undefined, dir).usable, false);
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
// ---- backedLine ----
|
|
|
|
test('backedLine: a Voyage security finding quoting an Outside line exactly → that line', async () => {
|
|
const { backedLine, readThreatModel } = await load();
|
|
const tm = readThreatModel(brief());
|
|
assert.equal(backedLine(own(), tm), OUTSIDE);
|
|
assert.equal(backedLine(own({ rule_key: 'security/shell', rule_keys: ['security/shell', 'SECURITY_INJECTION'] }), tm), OUTSIDE,
|
|
'a merged finding carrying a security key counts as a security finding');
|
|
});
|
|
|
|
test('backedLine: not exact, an Inside line, no ref, or no threat model → null', async () => {
|
|
const { backedLine, readThreatModel } = await load();
|
|
const tm = readThreatModel(brief());
|
|
assert.equal(backedLine(own({ threat_model_ref: INSIDE }), tm), null);
|
|
assert.equal(backedLine(own({ threat_model_ref: `${OUTSIDE} ` }), tm), null);
|
|
assert.equal(backedLine(own({ threat_model_ref: OUTSIDE.toUpperCase() }), tm), null);
|
|
assert.equal(backedLine(own({ threat_model_ref: `Outside: ${OUTSIDE}` }), tm), null);
|
|
assert.equal(backedLine(own({ threat_model_ref: undefined }), tm), null);
|
|
assert.equal(backedLine(own(), readThreatModel(brief(''))), null);
|
|
assert.equal(backedLine(own(), null), null);
|
|
assert.equal(backedLine(null, tm), null);
|
|
});
|
|
|
|
test('backedLine: a finding whose Outside line is gone counts in full (the brief is re-read)', async () => {
|
|
const { backedLine, readThreatModel } = await load();
|
|
const changed = readThreatModel(brief(`## Threat Model\n\n- Inside: ${INSIDE}\n- Outside: anyone at all\n`));
|
|
assert.equal(backedLine(own(), changed), null);
|
|
});
|
|
|
|
test('backedLine: only a security finding not from an engine can be outside (218)', async () => {
|
|
const { backedLine, readThreatModel, isOwnSecurityFinding } = await load();
|
|
const tm = readThreatModel(brief());
|
|
assert.equal(isOwnSecurityFinding(own()), true);
|
|
const engine = own({ engine: 'security-review' });
|
|
assert.equal(isOwnSecurityFinding(engine), false);
|
|
assert.equal(backedLine(engine, tm), null, 'an engine finding blocks as today');
|
|
const claudeSecurity = own({ engine: 'claude-security' });
|
|
assert.equal(isOwnSecurityFinding(claudeSecurity), false);
|
|
assert.equal(backedLine(claudeSecurity, tm), null, 'a Claude Security finding blocks as today (E 3.1)');
|
|
const enginesKey = own({ rule_key: 'SECURITY_REVIEW_VULNERABILITY' });
|
|
assert.equal(backedLine(enginesKey, tm), null, "the engines' key is never Voyage's own");
|
|
const notSecurity = own({ rule_key: 'MISSING_TEST' });
|
|
assert.equal(isOwnSecurityFinding(notSecurity), false);
|
|
assert.equal(backedLine(notSecurity, tm), null, 'only a security finding can be outside');
|
|
assert.equal(backedLine(own({ rule_key: 'security_injection' }), tm), OUTSIDE, 'key case is ignored, as the gate reads it');
|
|
});
|
|
|
|
test('backedLine: the engines\' key in rule_key is never Voyage\'s own, also when rule_keys names a key of its own (review fix)', async () => {
|
|
const { backedLine, readThreatModel, isOwnSecurityFinding } = await load();
|
|
const tm = readThreatModel(brief());
|
|
const mixed = own({ rule_key: 'SECURITY_REVIEW_VULNERABILITY', rule_keys: ['SECURITY_RISK'] });
|
|
assert.equal(isOwnSecurityFinding(mixed), false, 'rule_key holds the engines\' key: an engine finding, whatever rule_keys says');
|
|
assert.equal(backedLine(mixed, tm), null, 'it is never backed');
|
|
const inList = own({ rule_key: 'SECURITY_RISK', rule_keys: ['SECURITY_RISK', 'SECURITY_REVIEW_VULNERABILITY'] });
|
|
assert.equal(backedLine(inList, tm), null, 'the engines\' key in rule_keys is never backed either');
|
|
// Control: Voyage's own keys in both fields stay backed.
|
|
assert.equal(backedLine(own({ rule_key: 'SECURITY_RISK', rule_keys: ['SECURITY_RISK', 'SECURITY_INJECTION'] }), tm), OUTSIDE);
|
|
});
|