feat(gate): the brief's threat model on the plan side (decision 164, 218, 220)

plan-gate.mjs --record --brief <brief.md> (a known flag; its value is kept
out of the positional arguments, a missing value is a usage error) applies
the brief's threat model to the deduped findings before countRound when
threat-model.mjs usable() says it may: a backed finding (Voyage's own
security finding whose threat_model_ref equals an Outside: line's text)
leaves the counted set and the stop rule and goes to the new optional
verdict field known_outside ({id, rule_key, text, line}), carried to later
rounds until a reviewer marks it resolved or it is reported again under its
id. The verdict stores brief_path (carried when a later round has none).
The --record line adds known_outside (a count) and threat_model
({applied, why}): a threat model that is not usable backs nothing, every
security finding counts, and the output says why. extractVerdict
shape-checks both new fields.

The bare gate re-reads the brief at brief_path each run: an entry still
backed is a warning, PLAN_GATE_KNOWN_OUTSIDE; one no longer backed (line
gone, approval stale or not logged, brief unreadable, no brief_path, or not
a Voyage security finding) stops the plan, PLAN_GATE_OUTSIDE_NOT_BACKED: it
counts in full. gatePlan takes an optional threatModel for tests.

plan-review-dedup keeps threat_model_ref on a merge only when every merged
copy carries the same one; the S160 known issue is fixed: a null in a
reviewer's findings list is skipped instead of crashing the CLI.

plan-critic and scope-guardian: a security finding needs a concrete attack
path and high confidence (Claude Code's threshold, 167), uses
SECURITY_INJECTION or SECURITY_RISK, and quotes the Outside: line's text in
threat_model_ref when one applies; the json template carries the field.
/trekplan Phase 9 passes the brief path to plan-critic and --brief to
--record. Handover 4 names the two fields, the two codes and the flag.

Part 2, step 2.4 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 09:04:08 +01:00
commit 98171d7d3b
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
10 changed files with 612 additions and 26 deletions

View file

@ -291,7 +291,15 @@ implementation catches nothing, so the finding stays major. Give
A security finding uses the catalogue's security `rule_key`
(`SECURITY_INJECTION`, or `SECURITY_RISK` when no narrower key fits); the
engines' key is never yours.
engines' key is never yours. Raise one only with a concrete attack path (who
sends what, through which step, to what effect) and high confidence, Claude
Code's own threshold for a security finding (decision 167); a weakness with no
such path is no security finding. When the brief has a `## Threat Model` and
the attack path is one an `Outside:` line of it leaves out, put that line's text
(after `Outside: `) in `threat_model_ref`, exactly as the brief writes it: the
plan gate then lists the finding as a known issue instead of counting it,
while the brief's intent approval is current. Otherwise leave
`threat_model_ref` out, and the finding counts.
Your rating decides what happens next: `/trekplan` records your findings with
`plan-gate.mjs --record`, and the plan gate stops the plan while any BLOCKER
@ -384,7 +392,7 @@ this block inline *is* the hand-off; do not attempt to write a file.
{ "id": "<40-hex id from the current ## Critic Verdict>", "resolved": false }
],
"findings": [
{ "file": "<plan path or source file>", "line": 0, "line_end": 0, "rule_key": "<dimension/short-id>", "severity": "blocker|major|minor", "text": "<one-line finding>", "lowered_from": "major", "caught_by": "<the named test, Regression: command or gate>" }
{ "file": "<plan path or source file>", "line": 0, "line_end": 0, "rule_key": "<dimension/short-id>", "severity": "blocker|major|minor", "text": "<one-line finding>", "lowered_from": "major", "caught_by": "<the named test, Regression: command or gate>", "threat_model_ref": "<the text of the brief's Outside: line, exactly>" }
]
}
```
@ -404,7 +412,9 @@ finding the current `## Critic Verdict` lists; `resolved` is `true` or
`false`. `line_end` (optional) is the last line of a cited range, an integer
≥ `line`. `lowered_from` and `caught_by` go only on a finding lowered by rule
B: `"lowered_from": "major"`, and `caught_by` names the test, `Regression:`
command or gate that will fail on it; omit both otherwise.
command or gate that will fail on it; omit both otherwise. `threat_model_ref`
goes only on a security finding outside the brief's threat model: the text of
its `Outside:` line, exactly; omit it otherwise.
Be specific. Reference exact plan sections, step numbers, and file paths.
Never use "generally" or "usually" — cite the specific problem in this specific plan.

View file

@ -109,7 +109,15 @@ implementation catches nothing, so the finding stays major. Give
A security finding uses the catalogue's security `rule_key`
(`SECURITY_INJECTION`, or `SECURITY_RISK` when no narrower key fits); the
engines' key is never yours.
engines' key is never yours. Raise one only with a concrete attack path (who
sends what, through which step, to what effect) and high confidence, Claude
Code's own threshold for a security finding (decision 167); a weakness with no
such path is no security finding. When the brief has a `## Threat Model` and
the attack path is one an `Outside:` line of it leaves out, put that line's text
(after `Outside: `) in `threat_model_ref`, exactly as the brief writes it: the
plan gate then lists the finding as a known issue instead of counting it,
while the brief's intent approval is current. Otherwise leave
`threat_model_ref` out, and the finding counts.
Your findings go through `plan-gate.mjs --record` with plan-critic's; an open
blocker or major stops the plan. `severity` is exactly `blocker`, `major` or `minor`;
@ -174,7 +182,7 @@ this block inline *is* the hand-off; do not attempt to write a file.
{ "id": "<40-hex id from the current ## Critic Verdict>", "resolved": false }
],
"findings": [
{ "file": "<plan path or source file>", "line": 0, "line_end": 0, "rule_key": "<creep|gap|dependency|...>", "severity": "blocker|major|minor", "text": "<one-line finding>", "lowered_from": "major", "caught_by": "<the named test, Regression: command or gate>" }
{ "file": "<plan path or source file>", "line": 0, "line_end": 0, "rule_key": "<creep|gap|dependency|...>", "severity": "blocker|major|minor", "text": "<one-line finding>", "lowered_from": "major", "caught_by": "<the named test, Regression: command or gate>", "threat_model_ref": "<the text of the brief's Outside: line, exactly>" }
]
}
```
@ -193,7 +201,9 @@ finding the current `## Critic Verdict` lists; `resolved` is `true` or
`false`. `line_end` (optional) is the last line of a cited range, an integer
≥ `line`. `lowered_from` and `caught_by` go only on a finding lowered by rule
B: `"lowered_from": "major"`, and `caught_by` names the test, `Regression:`
command or gate that will fail on it; omit both otherwise.
command or gate that will fail on it; omit both otherwise. `threat_model_ref`
goes only on a security finding outside the brief's threat model: the text of
its `Outside:` line, exactly; omit it otherwise.
## When to use — examples