voyage/tests/validators/brief-threat-model.test.mjs
Kjell Tore Guttormsen 74f75dcb7d
feat(brief): optional threat model in the brief (decision 164, 218)
A brief may carry an optional `## Threat Model` with one line per bullet,
`- Inside: <text>` or `- Outside: <text>`. Present and malformed (no such
bullet, another label or form, empty text, two sections) is
BRIEF_THREAT_MODEL_MALFORMED; absent is today's rule. No brief_version bump
(the ceremony / artifact_store precedent). Sections are found fence- and
comment-aware through intent-approval's new sectionTexts, so the validator
and the hash count the same sections; brief-validator exports
parseThreatModel for the reader.

The intent hash covers `## Threat Model` when present (two sections -> null
hash, as Intent and Goal), so an edited, added or removed threat model
makes the approval stale; a brief without one hashes exactly as before. The
stale message and the /trekplan halt table name the section.

/trekbrief offers the section in its question round when the task touches
a guard, a gate, untrusted input, secrets or publishing, writes it only
when given, shows it verbatim at the Phase 4h approval, and --approve reads
it out. The template documents it; brief-gate-coverage pairs the new code.

Part 2, step 2.1 of the E plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:47:51 +01:00

214 lines
9.6 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';
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 TM = `## Threat Model
- Inside: a crafted file name in a pull request reaches the shell
- Outside: a local user who can already edit the repo's hooks
`;
const brief = (threat = '') => `---
type: trekbrief
brief_version: "2.2"
created: 2026-11-08
task: "Add an upload hook"
slug: upload-hook
project_dir: .claude/projects/2026-11-08-upload-hook/
research_topics: 0
research_status: complete
framing: new-direction
---
# Task
## TL;DR
An upload hook.
## Intent
Uploads arrive from strangers.
## Goal
A hook that checks each upload.
${threat}
## Success Criteria
- It checks.
`;
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/);
});