// 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'; 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 OUTSIDE = "a local user who can already edit the repo's hooks"; const INSIDE = 'a crafted file name in a pull request reaches the shell'; const TM = `## Threat Model\n\n- Inside: ${INSIDE}\n- Outside: ${OUTSIDE}\n`; const brief = (threat = TM, type = 'trekbrief') => `--- type: ${type} 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 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 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'); });