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

@ -5,16 +5,20 @@
// approved its intent in /trekbrief (Phase 4h, or `/trekbrief --approve`):
//
// intent_approved_hash: "sha256:<64 hex>" hash over ## Intent + ## Goal
// (+ ## Threat Model when present)
// intent_approved_at: "<ISO-8601>" when the stamp was written
//
// The hash binds the approval to the EXACT intent text: each section is
// normalized (lines trimmed, whitespace runs collapsed, blank lines dropped)
// and the pair is hashed. Indentation, spacing inside a line, blank lines and
// CRLF keep the approval. Line breaks are part of the text:
// and the pair is hashed. The optional ## Threat Model (E, decision 164) is
// part of what the operator approves: when the brief has one it joins the
// hash, so editing, adding or removing it makes the approval stale; a brief
// without one hashes exactly as before. Indentation, spacing inside a line,
// blank lines and CRLF keep the approval. Line breaks are part of the text:
// joining or splitting lines makes the approval stale, as does changing a
// word. That errs safe: /trekplan stops until the operator approves the new text.
// Each section must appear exactly once; a `## ` line inside a fenced code
// block or an HTML comment is text, not a heading.
// Each section must appear exactly once (## Threat Model at most once); a `## `
// line inside a fenced code block or an HTML comment is text, not a heading.
//
// WHAT THE MARKER DOES NOT PROVE. Same user, same machine: any session with
// write access to the brief can run `--stamp` itself, or write the two fields
@ -48,9 +52,11 @@ import { isMain } from '../util/is-main.mjs';
export const INTENT_HASH_FIELD = 'intent_approved_hash';
export const INTENT_APPROVED_AT_FIELD = 'intent_approved_at';
export const INTENT_SECTIONS = Object.freeze(['Intent', 'Goal']);
/** The optional section the intent approval also covers when present (E 2.1). */
export const THREAT_MODEL_SECTION = 'Threat Model';
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
const APPROVE_HINT = 'Run /trekbrief --approve <project-dir | brief-file> — the operator reads ## Intent + ## Goal and answers "Approve".';
const APPROVE_HINT = 'Run /trekbrief --approve <project-dir | brief-file> — the operator reads ## Intent + ## Goal (+ ## Threat Model) and answers "Approve".';
/**
* Line indexes of the `## ` headings that are real headings: a `## ` line
@ -91,6 +97,20 @@ function extractSections(lines, heads, heading) {
});
}
/**
* Every real `## <heading>` section's text in a brief body, one entry per
* occurrence; a `## ` line inside a fenced code block or an HTML comment is
* text, not a heading. The brief validator and the threat-model reader use it,
* so they count sections exactly as the intent hash does.
* @param {string} body
* @param {string} heading
* @returns {string[]}
*/
export function sectionTexts(body, heading) {
const lines = (body || '').split('\n');
return extractSections(lines, headingLines(lines), heading);
}
function normalize(text) {
return text.split(/\r?\n/)
.map((l) => l.trim().replace(/\s+/g, ' '))
@ -99,9 +119,10 @@ function normalize(text) {
}
/**
* Hash ## Intent + ## Goal of a brief body.
* @returns {{ hash: string|null, missing: string[], duplicate: string[] }} hash is null when a
* section is absent or empty, or appears more than once (which one would the approval cover?).
* Hash ## Intent + ## Goal (+ ## Threat Model when present) of a brief body.
* @returns {{ hash: string|null, missing: string[], duplicate: string[] }} hash is null when
* ## Intent or ## Goal is absent or empty, or any of the three appears more than once (which
* one would the approval cover?).
*/
export function computeIntentHash(body) {
const lines = (body || '').split('\n');
@ -116,7 +137,11 @@ export function computeIntentHash(body) {
if (!n && found.length <= 1) missing.push(h);
parts.push(n);
}
const threat = extractSections(lines, heads, THREAT_MODEL_SECTION);
if (threat.length > 1) duplicate.push(THREAT_MODEL_SECTION);
if (missing.length || duplicate.length) return { hash: null, missing, duplicate };
// Absent: two parts, the hash every brief had before E. Present: a third part.
if (threat.length === 1) parts.push(normalize(threat[0]));
const hex = createHash('sha256').update(JSON.stringify(parts), 'utf8').digest('hex');
return { hash: `sha256:${hex}`, missing, duplicate };
}
@ -163,14 +188,14 @@ export function checkIntentApprovalContent(text) {
errors.push(issue(
'BRIEF_INTENT_APPROVAL_INVALID',
`The approval cannot bind: ${unbindable(bound)}.`,
'Leave exactly one non-empty ## Intent and one ## Goal, then ' + APPROVE_HINT,
'Leave exactly one non-empty ## Intent and one ## Goal, and at most one ## Threat Model, then ' + APPROVE_HINT,
));
return result();
}
if (hash !== recorded) {
errors.push(issue(
'BRIEF_INTENT_APPROVAL_STALE',
'## Intent or ## Goal changed after the intent was approved — the approval covers the old text, not this one.',
'## Intent, ## Goal or ## Threat Model changed (or a ## Threat Model was added or removed) after the intent was approved — the approval covers the old text, not this one.',
APPROVE_HINT,
));
}