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:
Kjell Tore Guttormsen 2026-11-08 08:47:51 +01:00
commit 74f75dcb7d
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 350 additions and 23 deletions

View file

@ -11,6 +11,7 @@ import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
import { parseSuccessCriteria, runnableCheck, firstCommand } from '../verification/criteria-runner.mjs';
import { isMain } from '../util/is-main.mjs';
import { sectionTexts, THREAT_MODEL_SECTION } from './intent-approval.mjs';
export const BRIEF_REQUIRED_FRONTMATTER = ['type', 'brief_version', 'task', 'slug', 'research_topics', 'research_status'];
export const REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER = ['type', 'task', 'slug', 'project_dir', 'findings'];
@ -33,6 +34,43 @@ export const BRIEF_CEREMONY_DEFAULT = 'architectural';
export const BRIEF_ARTIFACT_STORE_VALUES = Object.freeze(['git', 'local']);
export const BRIEF_ARTIFACT_STORE_DEFAULT = 'local';
// E 2.1 — the optional `## Threat Model` (decision 164): one line per bullet,
// `- Inside: <text>` or `- Outside: <text>`. Additive, no brief_version bump
// (the ceremony / artifact_store precedent). Absent = today's rule: every
// security finding counts. Sections are found as the intent hash finds them
// (fence- and comment-aware, intent-approval.mjs), so the validator, the hash
// and the reader count the same sections.
const THREAT_BULLET_RE = /^\s*[-*+]\s+/;
const THREAT_LINE_RE = /^\s*-\s+(Inside|Outside):(.*)$/;
/**
* Read the optional `## Threat Model` of a brief body.
* Every bullet must be `- Inside: <text>` or `- Outside: <text>` with text;
* a line that is not a bullet is prose and ignored. Malformed = no such bullet,
* a bullet with another label or form, a label with empty text, or two sections.
* @param {string} body
* @returns {{present: boolean, inside: string[], outside: string[], problems: string[]}}
* inside / outside: each bullet's text after the label, trimmed
*/
export function parseThreatModel(body) {
const found = sectionTexts(body, THREAT_MODEL_SECTION);
const out = { present: found.length > 0, inside: [], outside: [], problems: [] };
if (found.length === 0) return out;
if (found.length > 1) out.problems.push(`## ${THREAT_MODEL_SECTION} appears ${found.length} times`);
for (const line of found[0].split(/\r?\n/)) {
if (!THREAT_BULLET_RE.test(line)) continue;
const m = THREAT_LINE_RE.exec(line);
if (!m) { out.problems.push(`"${line.trim()}" is not "- Inside: <text>" or "- Outside: <text>"`); continue; }
const text = m[2].trim();
if (text === '') { out.problems.push(`"${line.trim()}" has no text`); continue; }
(m[1] === 'Inside' ? out.inside : out.outside).push(text);
}
if (out.inside.length === 0 && out.outside.length === 0 && out.problems.length === 0) {
out.problems.push('it has no "- Inside: <text>" or "- Outside: <text>" bullet');
}
return out;
}
/** The brief's size class; absent or invalid → architectural (never lighter in doubt). */
export function briefCeremony(fm) {
return BRIEF_CEREMONY_VALUES.includes(fm?.ceremony) ? fm.ceremony : BRIEF_CEREMONY_DEFAULT;
@ -99,6 +137,16 @@ export function validateBriefContent(text, opts = {}) {
));
}
const threat = parseThreatModel(body);
if (threat.problems.length > 0) {
errors.push(issue(
'BRIEF_THREAT_MODEL_MALFORMED',
`## ${THREAT_MODEL_SECTION} is malformed: ${threat.problems.join('; ')}`,
'Keep one ## Threat Model with one line per bullet, each "- Inside: <text>" or "- Outside: <text>"; ' +
'or leave the section out, and every security finding counts.',
));
}
if (typeof fm.brief_version === 'string' || typeof fm.brief_version === 'number') {
const vm = String(fm.brief_version).match(/^(\d+)\.(\d+)$/);
if (vm) {