Quality only, no behaviour change (four cleanup reviewers, one implementer; npm test 2480/0 on the working tree): - threat-model.mjs splitBacked(): the one known_outside backing check of plan-gate (record and bare gate), verdict-gate and review-finalize, with one why-text; the engines' security keys derived from ENGINES; isSecurity. - coordinator-contract: "not a security engine" read from ENGINES (a string engine only), not the code-review literal. - review-rounds owns trailingBlock, findingsBlockSha, isMajor, isBlocker; review-answers imports them (review-rounds still imports nothing from it). prepare keeps the archive bytes it read instead of reading the previous archive again. - engine-compare exports rowIn and payloadEngine; finalize uses them. - plan-gate: one accepted_open builder; the stop-rule check hoisted; one top-level lookup for --record --brief. - security-engine: notFullShas(), runCli's usage flag, readScopeList shared with engine-findings; findReport and reviewRoundSummary module-private; two dead defaults gone. - tests: shared plan-gate harness and threat-model brief helpers; the [CNV] table cases call convertReport in-process, one CLI case per exit path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
9.1 KiB
JavaScript
179 lines
9.1 KiB
JavaScript
// tests/validators/brief-threat-model.test.mjs
|
|
// E 2.1 — the optional `## Threat Model` in a brief (decisions 164, 218):
|
|
// bullets `- Inside: <text>` / `- Outside: <text>`. Present and malformed (no
|
|
// bullet, another label, empty text, two such sections) is
|
|
// BRIEF_THREAT_MODEL_MALFORMED; absent is today's rule. No brief_version bump
|
|
// (the ceremony / artifact_store precedent). The intent hash covers the section
|
|
// when it is present, so an edited threat model makes the approval stale; a
|
|
// brief without one hashes as before.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { TM, briefWith } from '../helpers/threat-model-brief.mjs';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(HERE, '..', '..');
|
|
const validator = () => import(join(ROOT, 'lib', 'validators', 'brief-validator.mjs'));
|
|
const approval = () => import(join(ROOT, 'lib', 'validators', 'intent-approval.mjs'));
|
|
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
|
|
|
const brief = (threat = '') => briefWith(threat);
|
|
|
|
const codes = (r) => r.errors.map((e) => e.code);
|
|
const STAMP_AT = new Date('2026-11-08T08:00:00Z');
|
|
const FENCE = '```';
|
|
|
|
// ---- the validator ----
|
|
|
|
test('a well-formed threat model is valid; absent is today\'s rule', async () => {
|
|
const { validateBriefContent } = await validator();
|
|
assert.deepEqual(codes(validateBriefContent(brief(TM))), []);
|
|
assert.deepEqual(codes(validateBriefContent(brief())), []);
|
|
const onlyInside = '## Threat Model\n\n- Inside: untrusted input from a web form\n';
|
|
assert.deepEqual(codes(validateBriefContent(brief(onlyInside))), []);
|
|
});
|
|
|
|
test('malformed: no bullet, another label, empty text, two sections — each BRIEF_THREAT_MODEL_MALFORMED', async () => {
|
|
const { validateBriefContent } = await validator();
|
|
const cases = {
|
|
'no bullet': '## Threat Model\n\nAnything a stranger sends.\n',
|
|
'empty section': '## Threat Model\n\n',
|
|
'another label': '## Threat Model\n\n- Inside: uploads\n- Scope: the whole app\n',
|
|
'lower-case label': '## Threat Model\n\n- outside: a local user\n',
|
|
'empty text': '## Threat Model\n\n- Inside: uploads\n- Outside:\n',
|
|
'blank text': '## Threat Model\n\n- Inside: \n',
|
|
'two sections': `${TM}\n${TM}`,
|
|
};
|
|
for (const [name, section] of Object.entries(cases)) {
|
|
const r = validateBriefContent(brief(section));
|
|
assert.equal(r.valid, false, `${name}: must not be valid`);
|
|
assert.deepEqual(codes(r), ['BRIEF_THREAT_MODEL_MALFORMED'], name);
|
|
}
|
|
});
|
|
|
|
test('a ## Threat Model inside a code fence is text, not a section (no false malformed, no second section)', async () => {
|
|
const { validateBriefContent } = await validator();
|
|
const fenced = `${FENCE}markdown\n## Threat Model\n\nnot a bullet\n${FENCE}\n`;
|
|
assert.deepEqual(codes(validateBriefContent(brief(fenced))), []);
|
|
assert.deepEqual(codes(validateBriefContent(brief(`${TM}\n${fenced}`))), []);
|
|
});
|
|
|
|
test('the malformed message names the section and the bullet form', async () => {
|
|
const { validateBriefContent } = await validator();
|
|
const e = validateBriefContent(brief('## Threat Model\n\n- Scope: all\n')).errors[0];
|
|
assert.ok(e, 'an error');
|
|
assert.match(`${e.message} ${e.hint || ''}`, /## Threat Model/);
|
|
assert.match(`${e.message} ${e.hint || ''}`, /- Inside: <text>/);
|
|
assert.match(`${e.message} ${e.hint || ''}`, /- Outside: <text>/);
|
|
});
|
|
|
|
test('no brief_version bump: the template stays at 2.2 and documents the optional section with both bullets', () => {
|
|
const tpl = read('templates/trekbrief-template.md');
|
|
assert.match(tpl, /^brief_version: "2\.2"$/m);
|
|
const at = tpl.indexOf('\n## Threat Model');
|
|
assert.ok(at > 0, 'the template documents ## Threat Model');
|
|
const sec = tpl.slice(at, tpl.indexOf('\n## ', at + 4));
|
|
assert.match(sec, /optional/i);
|
|
assert.match(sec, /^- Inside: /m);
|
|
assert.match(sec, /^- Outside: /m);
|
|
});
|
|
|
|
// ---- the intent hash ----
|
|
|
|
test('red before E: an edited threat model makes the approval stale (BRIEF_INTENT_APPROVAL_STALE)', async () => {
|
|
const { stampIntentApproval, checkIntentApprovalContent } = await approval();
|
|
const stamped = stampIntentApproval(brief(TM), STAMP_AT).text;
|
|
assert.deepEqual(codes(checkIntentApprovalContent(stamped)), []);
|
|
const edited = stamped.replace("can already edit the repo's hooks", 'can reach the network');
|
|
assert.notEqual(edited, stamped);
|
|
assert.deepEqual(codes(checkIntentApprovalContent(edited)), ['BRIEF_INTENT_APPROVAL_STALE']);
|
|
});
|
|
|
|
test('adding or removing a threat model after the approval makes it stale', async () => {
|
|
const { stampIntentApproval, checkIntentApprovalContent } = await approval();
|
|
const plain = stampIntentApproval(brief(), STAMP_AT).text;
|
|
const added = plain.replace('## Success Criteria', `${TM}\n## Success Criteria`);
|
|
assert.deepEqual(codes(checkIntentApprovalContent(added)), ['BRIEF_INTENT_APPROVAL_STALE'], 'added');
|
|
const withTm = stampIntentApproval(brief(TM), STAMP_AT).text;
|
|
const removed = withTm.replace(TM, '');
|
|
assert.deepEqual(codes(checkIntentApprovalContent(removed)), ['BRIEF_INTENT_APPROVAL_STALE'], 'removed');
|
|
});
|
|
|
|
test('two ## Threat Model sections: null hash, the stamp refuses and the check is INVALID', async () => {
|
|
const { computeIntentHash, stampIntentApproval, checkIntentApprovalContent } = await approval();
|
|
const two = brief(`${TM}\n${TM}`);
|
|
const body = two.split('---\n').slice(2).join('---\n');
|
|
const h = computeIntentHash(body);
|
|
assert.equal(h.hash, null);
|
|
assert.deepEqual(h.duplicate, ['Threat Model']);
|
|
assert.equal(stampIntentApproval(two).stamped, false);
|
|
const forged = two.replace('framing: new-direction\n',
|
|
`framing: new-direction\nintent_approved_hash: "sha256:${'0'.repeat(64)}"\n`);
|
|
const r = checkIntentApprovalContent(forged);
|
|
assert.deepEqual(codes(r), ['BRIEF_INTENT_APPROVAL_INVALID']);
|
|
assert.match(r.errors[0].message, /## Threat Model appears more than once/);
|
|
});
|
|
|
|
test('whitespace-only reflow inside the threat model keeps the approval (normalized, as Intent and Goal)', async () => {
|
|
const { stampIntentApproval, checkIntentApprovalContent } = await approval();
|
|
const stamped = stampIntentApproval(brief(TM), STAMP_AT).text;
|
|
const reflowed = stamped.replace('- Inside: a crafted file name', '- Inside: a crafted file name');
|
|
assert.notEqual(reflowed, stamped);
|
|
assert.deepEqual(codes(checkIntentApprovalContent(reflowed)), []);
|
|
});
|
|
|
|
test('a brief without a threat model hashes exactly as before (no stamped brief goes stale)', async () => {
|
|
const { computeIntentHash } = await approval();
|
|
const { createHash } = await import('node:crypto');
|
|
const body = brief().split('---\n').slice(2).join('---\n');
|
|
const parts = ['Uploads arrive from strangers.', 'A hook that checks each upload.'];
|
|
const expected = `sha256:${createHash('sha256').update(JSON.stringify(parts), 'utf8').digest('hex')}`;
|
|
assert.equal(computeIntentHash(body).hash, expected);
|
|
});
|
|
|
|
test('the stale message names ## Threat Model (intent-approval.mjs and the /trekplan halt table)', async () => {
|
|
const { stampIntentApproval, checkIntentApprovalContent } = await approval();
|
|
const stamped = stampIntentApproval(brief(TM), STAMP_AT).text;
|
|
const e = checkIntentApprovalContent(stamped.replace('reaches the shell', 'reaches a log')).errors[0];
|
|
assert.ok(e, 'a stale error');
|
|
assert.match(e.message, /## Threat Model/);
|
|
const plan = read('commands/trekplan.md');
|
|
const row = plan.split('\n').find((l) => l.startsWith('| `BRIEF_INTENT_APPROVAL_STALE` |'));
|
|
assert.ok(row, 'the halt table has the stale row');
|
|
assert.match(row, /`## Threat Model`/);
|
|
});
|
|
|
|
// ---- /trekbrief prose ----
|
|
|
|
function section(text, heading) {
|
|
const start = text.indexOf(`\n## ${heading}`);
|
|
assert.ok(start >= 0, `missing ## ${heading}`);
|
|
const next = text.indexOf('\n## ', start + 4);
|
|
return text.slice(start, next < 0 ? undefined : next);
|
|
}
|
|
|
|
test('/trekbrief offers the section when the task touches a guard, a gate, untrusted input, secrets or publishing', () => {
|
|
const p3 = section(read('commands/trekbrief.md'), 'Phase 3').replace(/\s+/g, ' ');
|
|
assert.match(p3, /`## Threat Model`/);
|
|
for (const w of ['a guard', 'a gate', 'untrusted input', 'secrets', 'publishing']) {
|
|
assert.ok(p3.includes(w), `Phase 3 names «${w}» as a reason to offer the threat model`);
|
|
}
|
|
});
|
|
|
|
test('/trekbrief writes the section only when given, as one-line Inside/Outside bullets', () => {
|
|
const text = read('commands/trekbrief.md');
|
|
const draft = text.slice(text.indexOf('**Step 4a'), text.indexOf('**Step 4b')).replace(/\s+/g, ' ');
|
|
assert.match(draft, /\*\*Threat Model:\*\*/);
|
|
assert.match(draft, /`- Inside: <text>`/);
|
|
assert.match(draft, /`- Outside: <text>`/);
|
|
});
|
|
|
|
test('/trekbrief Phase 4h shows ## Threat Model verbatim at the approval, and --approve reads it out', () => {
|
|
const p4h = section(read('commands/trekbrief.md'), 'Phase 4h').replace(/\s+/g, ' ');
|
|
assert.match(p4h, /`## Intent`, `## Goal` and, when present, `## Threat Model`[^.]*\*\*verbatim\*\*/);
|
|
assert.match(p4h, /`--approve` mode[^.]*`## Threat Model`/);
|
|
assert.match(p4h, /`## Threat Model`[^.]*afterwards makes the approval stale/);
|
|
});
|