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
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue