// tests/validators/brief-threat-model.test.mjs // E 2.1 — the optional `## Threat Model` in a brief (decisions 164, 218): // bullets `- Inside: ` / `- Outside: `. 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: /); assert.match(`${e.message} ${e.hint || ''}`, /- Outside: /); }); 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: `/); assert.match(draft, /`- Outside: `/); }); 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/); });