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>
This commit is contained in:
parent
07a3c06c57
commit
74f75dcb7d
7 changed files with 350 additions and 23 deletions
|
|
@ -129,6 +129,7 @@ const IN_SCOPE_CODES = [
|
|||
'BRIEF_MISSING_SECTION',
|
||||
'BRIEF_INVALID_FRAMING',
|
||||
'BRIEF_MISSING_FRAMING',
|
||||
'BRIEF_THREAT_MODEL_MALFORMED',
|
||||
];
|
||||
|
||||
const CASES = {
|
||||
|
|
@ -164,6 +165,10 @@ const CASES = {
|
|||
fail: GOOD_BRIEF_22.replace('framing: new-direction\n', ''),
|
||||
pass: GOOD_BRIEF_22,
|
||||
},
|
||||
BRIEF_THREAT_MODEL_MALFORMED: {
|
||||
fail: GOOD_BRIEF_22.replace('## Success Criteria', '## Threat Model\n\n- Scope: the API\n\n## Success Criteria'),
|
||||
pass: GOOD_BRIEF_22.replace('## Success Criteria', '## Threat Model\n\n- Inside: a forged token\n- Outside: a stolen signing key\n\n## Success Criteria'),
|
||||
},
|
||||
};
|
||||
|
||||
test('gate coverage — CASES keys exactly match IN_SCOPE_CODES (no silent gap)', () => {
|
||||
|
|
|
|||
214
tests/validators/brief-threat-model.test.mjs
Normal file
214
tests/validators/brief-threat-model.test.mjs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// 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/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue