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:
parent
5cb2918baa
commit
98171d7d3b
10 changed files with 612 additions and 26 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -977,7 +977,9 @@ This spawn passes no `model:`: the agent's own `model: opus` applies.
|
|||
|
||||
**plan-critic** — adversarial review of the plan.
|
||||
Prompt: "Review this implementation plan for the task: {task}.
|
||||
Plan file: {plan path}. Read it and find every problem — missing steps,
|
||||
Plan file: {plan path}. Brief file: {brief_path} (its `## Threat Model`, when
|
||||
it has one, is what a security finding's `threat_model_ref` quotes).
|
||||
Read the plan and find every problem — missing steps,
|
||||
wrong ordering, fragile assumptions, missing error handling, scope creep,
|
||||
underspecified steps. Rate each finding as blocker, major, or minor.
|
||||
Follow the writing-criteria skill (every check is one command the criteria
|
||||
|
|
@ -1014,7 +1016,7 @@ output goes straight on to the plan gate, which writes this round's verdict
|
|||
into the plan's `## Critic Verdict` section:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/review/plan-review-dedup.mjs --stdin <<'JSON' | node ${CLAUDE_PLUGIN_ROOT}/lib/validators/plan-gate.mjs --record "{plan_path}" --reviewed {the hash from --hash}
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/review/plan-review-dedup.mjs --stdin <<'JSON' | node ${CLAUDE_PLUGIN_ROOT}/lib/validators/plan-gate.mjs --record "{plan_path}" --reviewed {the hash from --hash} --brief "{brief_path}"
|
||||
{ "plan_critic": <plan-critic's json block>, "scope_guardian": <scope-guardian's json block> }
|
||||
JSON
|
||||
```
|
||||
|
|
@ -1032,6 +1034,15 @@ snapshot (`not kept: no git repository`), so its next round counts fully.
|
|||
A failed snapshot write does not fail the round. `stop_rule` is this round's
|
||||
stop-rule decision (see "The stop rule"), and `reason` names the code of a
|
||||
refusal.
|
||||
`--brief` applies the brief's `## Threat Model` (decisions 164, 218): a
|
||||
security finding of the two reviewers whose `threat_model_ref` equals an
|
||||
`Outside:` line's text leaves the count and the stop rule and is listed in
|
||||
the verdict's `known_outside` (`known_outside` in the line is how many);
|
||||
`threat_model` says whether the model was applied and, when not (no section,
|
||||
the intent approval stale or not logged), why: then every security finding
|
||||
counts. The gate re-reads the brief each time: a listed finding still backed
|
||||
is a warning (`PLAN_GATE_KNOWN_OUTSIDE`); one whose line is
|
||||
gone stops the plan (`PLAN_GATE_OUTSIDE_NOT_BACKED`), it counts in full.
|
||||
Exit 1 means the round cap is spent (see "After round 3") or the stop rule
|
||||
fired after the previous round (`reason: PLAN_GATE_STOP_RULE`). Exit 2 means the
|
||||
round cannot be recorded: broken input, a reviewer missing from it (an absent
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ prose form (relaxed in S26 so the template's prose emission parses).
|
|||
- at least one step has `End-to-end:` with a backticked command — the "run the thing" step;
|
||||
- the optional step fields `Interface in:`, `Interface out:`, `Review focus:` are never empty when present (consumers tolerate their absence);
|
||||
- `ceremony` is one of the three classes; an `architectural` plan has `## Design` with the subsections Architecture, Data Flow, Interfaces, Data Models, Error Handling, Module Map, Architecture Rules, Requirements Check, plus `## Test Strategy`; the module map (mermaid) has no cycle; every architecture rule says "checked by `<command>`" (with a Proof column naming an existing step) or "advisory — `.claude/rules/<file>.md`".
|
||||
6. **`## Critic Verdict`** (written by `lib/validators/plan-gate.mjs --record`, never by hand): one fenced `json` block, schema `voyage.plan-verdict/1` — `round`, `max_rounds` (3), `extra_rounds`, `plan_sha256`, `verdict` (`BLOCK` | `REVISE` | `PASS`), `counts`, `findings` (deduplicated, each with `severity`). `plan_sha256` hashes the plan minus its review log (the `> **Plan quality:` line and the sections Critic Verdict, Revisions, Plan Quality Score; an old Adversarial Pass 2 section is plan text and hashed), with CRLF and line-end whitespace normalized. The verdict sits inside the plan because Claude Code's plan approval hands a hook the plan file's text and nothing beside it. **Optional rule-A fields** (decision 43; `DIFF_FIELDS` in `lib/review/critic-diff.mjs`): each one is optional, and absent means round-1 behaviour, so an older block gives the same exit codes. Top level: `diff_base` (the sha256 the round was counted against), `diff_note` (one line: why a round from round 2 on was counted fully), `resolved` (the ids of previous findings that left the count). Per finding: `op_line` and `op_line_end` (the cited lines in the operative text), `line_end`, `rule_keys` (the union of the keys dedup merged), `original_severity` (what a reviewer rated, set whenever the counted `severity` differs), `lowered_by` (`rule A: outside the diff since round <P>` or `rule B: <caught_by>`), `caught_by` (the test, `Regression:` command or gate a rule-B lowering names), `lowering_refused` (why a rule-B lowering was refused) and `carried_from` (the round a carried finding first appeared). `severity` is always the counted severity. `recordVerdict` called without `base`, `baseNote` and `planPaths` records as before, plus the rule-B record, and a per-finding `rule_keys` from dedup is always passed through (an optional field). **Optional stop-rule fields** (decisions 217, 220; `lib/review/stop-rule.mjs`): `rounds` (one `{round, blocking, newByClass}` per recorded round, carried from the previous verdict; `newByClass` is `null` for a round counted in full) and `stop_rule` (`{fired, reason: same-class | not-fewer | null, classes, perRound}`); a verdict without them starts its history at the next round. `accepted_open.under` is `"stop-rule"` for an acceptance made after the stop rule fired.
|
||||
6. **`## Critic Verdict`** (written by `lib/validators/plan-gate.mjs --record`, never by hand): one fenced `json` block, schema `voyage.plan-verdict/1` — `round`, `max_rounds` (3), `extra_rounds`, `plan_sha256`, `verdict` (`BLOCK` | `REVISE` | `PASS`), `counts`, `findings` (deduplicated, each with `severity`). `plan_sha256` hashes the plan minus its review log (the `> **Plan quality:` line and the sections Critic Verdict, Revisions, Plan Quality Score; an old Adversarial Pass 2 section is plan text and hashed), with CRLF and line-end whitespace normalized. The verdict sits inside the plan because Claude Code's plan approval hands a hook the plan file's text and nothing beside it. **Optional rule-A fields** (decision 43; `DIFF_FIELDS` in `lib/review/critic-diff.mjs`): each one is optional, and absent means round-1 behaviour, so an older block gives the same exit codes. Top level: `diff_base` (the sha256 the round was counted against), `diff_note` (one line: why a round from round 2 on was counted fully), `resolved` (the ids of previous findings that left the count). Per finding: `op_line` and `op_line_end` (the cited lines in the operative text), `line_end`, `rule_keys` (the union of the keys dedup merged), `original_severity` (what a reviewer rated, set whenever the counted `severity` differs), `lowered_by` (`rule A: outside the diff since round <P>` or `rule B: <caught_by>`), `caught_by` (the test, `Regression:` command or gate a rule-B lowering names), `lowering_refused` (why a rule-B lowering was refused) and `carried_from` (the round a carried finding first appeared). `severity` is always the counted severity. `recordVerdict` called without `base`, `baseNote` and `planPaths` records as before, plus the rule-B record, and a per-finding `rule_keys` from dedup is always passed through (an optional field). **Optional stop-rule fields** (decisions 217, 220; `lib/review/stop-rule.mjs`): `rounds` (one `{round, blocking, newByClass}` per recorded round, carried from the previous verdict; `newByClass` is `null` for a round counted in full) and `stop_rule` (`{fired, reason: same-class | not-fewer | null, classes, perRound}`); a verdict without them starts its history at the next round. `accepted_open.under` is `"stop-rule"` for an acceptance made after the stop rule fired. **Optional threat-model fields** (E 2.4, decisions 164, 218; `lib/review/threat-model.mjs`): `known_outside` (one `{id, rule_key, text, line}` per security finding of plan-critic or scope-guardian whose `threat_model_ref` equals the text of an `Outside:` line of the brief's `## Threat Model`, `line` being that text; it left the counted findings and the stop rule, and stays listed in later rounds until a reviewer marks it resolved or it is reported again under its id) and `brief_path` (the brief `--record --brief` read, carried when a later round has none); a verdict without them lists no known issue.
|
||||
7. **Every plan_version, no version bump** (a plan without the field validates unchanged; voyage-lekerepo trial 2, 2026-10-08):
|
||||
- **K10 — `Regression:`** (optional step field): ONE backticked command the criteria runner's allowlist runs, with no pipe and no `&&` (first backticked span, as for `Verify:`). The executor runs it green before the step's checkpoint; a step without it runs nothing new. `Verify:` stays the step's own red-first test. One that can never run is `PLAN_STEP_REGRESSION_NOT_RUNNABLE` (an error; a warning under `--soft`).
|
||||
- **K11 — `On failure:`** opens with one of `revert`, `retry`, `skip`, `escalate` (case-insensitive; a leading `**`, `_`, backtick or `-` is ignored, other leading characters are not; `onFailureAction` in `lib/parsers/plan-schema.mjs`), the words `/trekexecute` reads; any other opening is the warning `PLAN_STEP_ON_FAILURE_UNKNOWN`. A missing `On failure:` is unchanged (the executor defaults to `escalate`).
|
||||
|
|
@ -298,7 +298,7 @@ The strongest validation in the entire pipeline. `/trekplan` Phase 8 **must** ru
|
|||
| `PLAN_STEP_PATH_NOT_A_PATH` | 1.9: a `Files:` entry opens with a backticked span that is no path (a call `parse()`, an option, a URL, a backticked placeholder such as `` `TBD` `` or `` `none` ``, a mixed-case bare name such as `` `Parser` ``; operator decision 22); error, a warning under `--soft` | Put the path first; the span goes in the description; a file named `Parser` is written `./Parser` |
|
||||
| `PLAN_STEP_PATH_UNTICKED` | Below 1.9, warning: the `Files:` reader found a path outside backticks; the plan is read as before (a warning under `--strict` too) | Backtick the path; from 1.9 an unticked path is refused |
|
||||
|
||||
**The plan gate (K3).** `node lib/validators/plan-gate.mjs <plan.md> [--json]` decides whether a plan may go on: **0** = it validates strict, its `## Critic Verdict` matches the current text, no BLOCKER is open, and every open MAJOR was accepted by the operator; **1** = stop, with `reasons` (`PLAN_GATE_INVALID_PLAN`, `PLAN_GATE_VERSION` — below plan_version 1.8, `PLAN_GATE_NO_VERDICT`, `PLAN_GATE_VERDICT_MALFORMED`, `PLAN_GATE_VERDICT_STALE`, `PLAN_GATE_OPEN_BLOCKER`, `PLAN_GATE_ROUNDS_EXHAUSTED`, `PLAN_GATE_OPEN_MAJOR` — rounds left: revise, `PLAN_GATE_OPEN_MAJOR_FINAL` — after the last round: ask the operator, `PLAN_GATE_STOP_RULE` — the stop rule fired with a blocking finding open and not accepted; it replaces the open-blocker and open-major reasons) and `warnings` (`PLAN_GATE_OPEN_MAJOR` for majors the operator accepted, `PLAN_GATE_OPEN_BLOCKER` for blockers accepted under the stop rule); **2** = could not read the plan. `--hash <plan.md>` prints the operative hash; Phase 9 takes it before the reviewers read the plan. `--record <plan.md> --reviewed <hash>` reads plan-review-dedup output on stdin (which lists the reviewers that delivered, `sources`) and writes the next round; exit 2 when a reviewer is missing or the plan changed since `--hash`; it refuses round 4 (exit 1, stop and ask) unless `--extra-round` follows the operator's yes, and refuses that flag before the cap is spent; after a verdict whose stop rule fired it refuses any new round (exit 1, `PLAN_GATE_STOP_RULE`, `--extra-round` included). The stop rule: no new round after round N ≥ 2 when one finding class has new blocking findings in rounds N−1 and N, or when the blocking findings did not get fewer; a class is the catalogue rule key, else the reviewer's dimension (the `rule_key` before the first `/`). Unrated findings count as blockers. The review log is excluded from the hash only up to its first other heading, so a step placed under it is hashed. `--accept-open <plan.md>` records the operator's "build as is" for the current round's open MAJORs (`accepted_open` in the verdict block, bound to the round and the plan hash; only after the last round; never for a BLOCKER; exit 1 when rounds are left, the verdict is stale, a blocker is open or no major is). After the stop rule fired it is allowed with rounds left and accepts the open BLOCKERs and MAJORs as known issues, except a counting security finding (exit 1, `PLAN_GATE_SECURITY_OPEN`). Operator decision 2026-10-08: open majors used to be warnings, and a weakness the critics named was built and came back from `/trekreview` as MAJOR. plan-critic and scope-guardian rate on the review catalogue's tiers. The verdict is a trace of the review, not a signature.
|
||||
**The plan gate (K3).** `node lib/validators/plan-gate.mjs <plan.md> [--json]` decides whether a plan may go on: **0** = it validates strict, its `## Critic Verdict` matches the current text, no BLOCKER is open, and every open MAJOR was accepted by the operator; **1** = stop, with `reasons` (`PLAN_GATE_INVALID_PLAN`, `PLAN_GATE_VERSION` — below plan_version 1.8, `PLAN_GATE_NO_VERDICT`, `PLAN_GATE_VERDICT_MALFORMED`, `PLAN_GATE_VERDICT_STALE`, `PLAN_GATE_OPEN_BLOCKER`, `PLAN_GATE_ROUNDS_EXHAUSTED`, `PLAN_GATE_OPEN_MAJOR` — rounds left: revise, `PLAN_GATE_OPEN_MAJOR_FINAL` — after the last round: ask the operator, `PLAN_GATE_STOP_RULE` — the stop rule fired with a blocking finding open and not accepted; it replaces the open-blocker and open-major reasons, `PLAN_GATE_OUTSIDE_NOT_BACKED` — a `known_outside` entry the brief at `brief_path` no longer backs: its `Outside:` line is gone, the threat model is not usable (no section, malformed, the intent approval stale or not logged, the brief unreadable) or no `brief_path` is named; it counts in full) and `warnings` (`PLAN_GATE_OPEN_MAJOR` for majors the operator accepted, `PLAN_GATE_OPEN_BLOCKER` for blockers accepted under the stop rule, `PLAN_GATE_KNOWN_OUTSIDE` for `known_outside` entries the brief still backs); **2** = could not read the plan. `--hash <plan.md>` prints the operative hash; Phase 9 takes it before the reviewers read the plan. `--record <plan.md> --reviewed <hash>` reads plan-review-dedup output on stdin (which lists the reviewers that delivered, `sources`) and writes the next round; exit 2 when a reviewer is missing or the plan changed since `--hash`; it refuses round 4 (exit 1, stop and ask) unless `--extra-round` follows the operator's yes, and refuses that flag before the cap is spent; after a verdict whose stop rule fired it refuses any new round (exit 1, `PLAN_GATE_STOP_RULE`, `--extra-round` included). `--brief <brief.md>` (a value, never the plan path) applies the brief's threat model before the round is counted, only when it is usable (present, well formed, and the intent approval current by the marker and the approval log, the two checks `/trekplan` Phase 1 runs): a security finding of the two reviewers (never an engine's) whose `threat_model_ref` equals an `Outside:` line's text goes to `known_outside`; otherwise every security finding counts, and the output's `threat_model` (`{applied, why}`) says why. The output line also carries `known_outside` (how many are listed). plan-review-dedup keeps a finding's `threat_model_ref` on a merge only when every merged copy carries the same one. The stop rule: no new round after round N ≥ 2 when one finding class has new blocking findings in rounds N−1 and N, or when the blocking findings did not get fewer; a class is the catalogue rule key, else the reviewer's dimension (the `rule_key` before the first `/`). Unrated findings count as blockers. The review log is excluded from the hash only up to its first other heading, so a step placed under it is hashed. `--accept-open <plan.md>` records the operator's "build as is" for the current round's open MAJORs (`accepted_open` in the verdict block, bound to the round and the plan hash; only after the last round; never for a BLOCKER; exit 1 when rounds are left, the verdict is stale, a blocker is open or no major is). After the stop rule fired it is allowed with rounds left and accepts the open BLOCKERs and MAJORs as known issues, except a counting security finding (exit 1, `PLAN_GATE_SECURITY_OPEN`). Operator decision 2026-10-08: open majors used to be warnings, and a weakness the critics named was built and came back from `/trekreview` as MAJOR. plan-critic and scope-guardian rate on the review catalogue's tiers. The verdict is a trace of the review, not a signature.
|
||||
|
||||
**Rule A and the diff (decision 43).** Round 1 is unchanged. From round 2 on, `--record` counts the round against the operative text the previous round reviewed, and both reviewers mark each previous finding by id (`previous`). The gate counts by this rule, not the session:
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@
|
|||
// go through the text passes, and a second survivor with a taken id gets
|
||||
// `<rule_key>#n` (n = 2, 3, …) in its id.
|
||||
//
|
||||
// The brief's threat model (E 2.4, decisions 164, 218): a finding keeps its
|
||||
// `threat_model_ref` (the text of the brief's `Outside:` line it quotes), and
|
||||
// a merge keeps it only when every merged copy carries the same one; one copy
|
||||
// without it, or two different ones, drop it, so the plan gate counts the
|
||||
// merged finding in full. A non-object in a reviewer's findings list (a
|
||||
// `null`) is skipped instead of taking the CLI down (the S160 known issue).
|
||||
//
|
||||
// Provenance is preserved on the surviving finding's `raised_by` array.
|
||||
// Severity is kept (normalized to blocker | major | minor, else null). A merge
|
||||
// keeps the copy rule B counts highest on the merged keys, then the higher
|
||||
|
|
@ -117,6 +124,8 @@ export function ruleB(f, rated) {
|
|||
// ---- merge ---------------------------------------------------------------------
|
||||
|
||||
function mergeInto(survivor, other) {
|
||||
// Kept only when every merged copy carries the same one (E 2.4).
|
||||
if (survivor.threat_model_ref !== other.threat_model_ref) delete survivor.threat_model_ref;
|
||||
for (const a of other.raised_by) {
|
||||
if (!survivor.raised_by.includes(a)) survivor.raised_by.push(a);
|
||||
}
|
||||
|
|
@ -209,7 +218,8 @@ function normalizeAgentPayload(payload, fallbackAgent) {
|
|||
const agent = (typeof payload.agent === 'string' && payload.agent.length > 0)
|
||||
? payload.agent
|
||||
: fallbackAgent;
|
||||
const findings = Array.isArray(payload.findings) ? payload.findings : [];
|
||||
// A non-object entry (a `null`) is no finding: skipped, never thrown on (S160).
|
||||
const findings = Array.isArray(payload.findings) ? payload.findings.filter(f => f !== null && typeof f === 'object') : [];
|
||||
const previous = Array.isArray(payload.previous) ? payload.previous : [];
|
||||
return { agent, pairs: findings.map(f => ({ agent, finding: f })), previous };
|
||||
}
|
||||
|
|
@ -250,6 +260,9 @@ function annotate(finding, agent) {
|
|||
out.caught_by = finding.caught_by;
|
||||
}
|
||||
if (typeof ruleKey === 'string') out.rule_keys = [ruleKey];
|
||||
if (typeof finding.threat_model_ref === 'string' && finding.threat_model_ref !== '') {
|
||||
out.threat_model_ref = finding.threat_model_ref;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
// Prints the sha256 of the plan's operative text. Phase 9 takes it BEFORE
|
||||
// the reviewers read the plan and hands it to --record.
|
||||
//
|
||||
// node plan-gate.mjs --record <plan.md> --reviewed <sha256> [--extra-round]
|
||||
// node plan-gate.mjs --record <plan.md> --reviewed <sha256> [--extra-round] [--brief <brief.md>]
|
||||
// stdin = plan-review-dedup.mjs output. Writes this round's verdict into
|
||||
// the plan. 0 = written; 1 = refused: the previous verdict's stop rule
|
||||
// fired (`PLAN_GATE_STOP_RULE`, below; `--extra-round` cannot open it) or
|
||||
|
|
@ -29,8 +29,11 @@
|
|||
// before the plan is written. Output: one JSON line, which adds
|
||||
// `diff_base`, `diff_note`, `lowered` (a number) and `snapshot` ("kept: ..."
|
||||
// or "not kept: <reason>"; a failed snapshot write does not fail the round),
|
||||
// `stop_rule` (this round's decision, or on a refusal the one that fired)
|
||||
// and `reason` (`PLAN_GATE_STOP_RULE` on that refusal, else null).
|
||||
// `stop_rule` (this round's decision, or on a refusal the one that fired),
|
||||
// `reason` (`PLAN_GATE_STOP_RULE` on that refusal, else null),
|
||||
// `known_outside` (how many findings the threat model lists as known
|
||||
// issues) and `threat_model` ({applied, why}: whether the brief's threat
|
||||
// model was applied, and why not; below).
|
||||
//
|
||||
// node plan-gate.mjs --diff <plan.md>
|
||||
// Prints what the next critic round reviews: `diff-base: <sha256> (round N)`
|
||||
|
|
@ -111,6 +114,24 @@
|
|||
// operator accepts the open findings (--accept-open) or the direction
|
||||
// changes. A verdict without `rounds` starts its history at the next round,
|
||||
// so an older block never fires on its first new round.
|
||||
//
|
||||
// The brief's threat model (E 2.4, decisions 164, 218, 220;
|
||||
// lib/review/threat-model.mjs): `--record --brief <brief.md>` applies it to
|
||||
// the deduped findings before countRound, when it is usable (present, well
|
||||
// formed, and the intent approval current by the marker and the approval
|
||||
// log). A backed finding — Voyage's own security finding (no engine) whose
|
||||
// `threat_model_ref` equals an `Outside:` line's text exactly — leaves the
|
||||
// counted set and the stop rule and goes to the optional verdict field
|
||||
// `known_outside` ({id, rule_key, text, line}), carried to later rounds until
|
||||
// a reviewer marks it resolved (or it counts again under its id); the verdict
|
||||
// stores `brief_path` (carried when a later round has no --brief). A threat
|
||||
// model that is not usable backs nothing: every security finding counts, and
|
||||
// `threat_model.why` says why. The bare gate re-reads the brief at
|
||||
// `brief_path` on every run: an entry still backed is a warning
|
||||
// (PLAN_GATE_KNOWN_OUTSIDE); one no longer backed (its line gone, the
|
||||
// approval stale, the brief unreadable, no brief_path) stops the plan
|
||||
// (PLAN_GATE_OUTSIDE_NOT_BACKED): it counts in full, and a security finding
|
||||
// is never accepted.
|
||||
|
||||
import {
|
||||
readFileSync, existsSync, writeFileSync, renameSync, rmSync, statSync, mkdirSync, realpathSync,
|
||||
|
|
@ -122,6 +143,7 @@ import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from
|
|||
import { normalizeSeverity, isSecurity } from '../review/plan-review-dedup.mjs';
|
||||
import { countRound, lineDiff, oneLine } from '../review/critic-diff.mjs';
|
||||
import { roundSummary, decideStop, describeStop, STOP_REASONS } from '../review/stop-rule.mjs';
|
||||
import { usable as threatModelUsable, backedLine } from '../review/threat-model.mjs';
|
||||
import { resolveRepo } from '../verification/repo-tools.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
|
|
@ -232,9 +254,9 @@ export function extractVerdict(text) {
|
|||
Number.isInteger(v.extra_rounds ?? 0) && (v.extra_rounds ?? 0) >= 0 &&
|
||||
typeof v.plan_sha256 === 'string' && Array.isArray(v.findings) &&
|
||||
v.findings.every(f => f && typeof f === 'object' && typeof f.severity === 'string') &&
|
||||
okRounds(v.rounds) && okStopRule(v.stop_rule);
|
||||
okRounds(v.rounds) && okStopRule(v.stop_rule) && okKnownOutside(v.known_outside) && okBriefPath(v.brief_path);
|
||||
if (!okShape) {
|
||||
return { ...none, found: true, error: `verdict block is not ${VERDICT_SCHEMA} (schema, round, max_rounds <= ${MAX_ROUNDS}, extra_rounds, plan_sha256, findings with severity, optional rounds and stop_rule)` };
|
||||
return { ...none, found: true, error: `verdict block is not ${VERDICT_SCHEMA} (schema, round, max_rounds <= ${MAX_ROUNDS}, extra_rounds, plan_sha256, findings with severity, optional rounds, stop_rule, known_outside and brief_path)` };
|
||||
}
|
||||
return { found: true, placeholder: false, verdict: v, error: null };
|
||||
}
|
||||
|
|
@ -257,6 +279,24 @@ function okStopRule(s) {
|
|||
Array.isArray(s.classes) && s.classes.every(c => typeof c === 'string') && okRounds(s.perRound);
|
||||
}
|
||||
|
||||
const isStrOrNull = x => x === null || typeof x === 'string';
|
||||
|
||||
/** Optional `known_outside`: a list of {id: string|null, rule_key: string|null, text: string, line: non-empty string}. */
|
||||
function okKnownOutside(k) {
|
||||
if (k === undefined) return true;
|
||||
return Array.isArray(k) && k.every(e => isPlainObject(e) && isStrOrNull(e.id) && isStrOrNull(e.rule_key) &&
|
||||
typeof e.text === 'string' && typeof e.line === 'string' && e.line !== '');
|
||||
}
|
||||
|
||||
/** Optional `brief_path`: a non-empty string. */
|
||||
const okBriefPath = p => p === undefined || (typeof p === 'string' && p !== '');
|
||||
|
||||
/** A previous known_outside entry leaves on a reviewer's resolved: true, and nobody's false. */
|
||||
function knownResolved(e, marks) {
|
||||
const ms = marks.filter(m => m && typeof m.id === 'string' && m.id === e.id);
|
||||
return ms.some(m => m.resolved === true) && !ms.some(m => m.resolved === false);
|
||||
}
|
||||
|
||||
function verdictFor(counts) {
|
||||
if (counts.blocker > 0) return 'BLOCK';
|
||||
if (counts.major > 0) return 'REVISE';
|
||||
|
|
@ -302,14 +342,18 @@ function missingReviewers(dedup) {
|
|||
* @param {{findings: Array<object>, sources: Array<{agent: string, present: boolean}>}} dedup
|
||||
* plan-review-dedup.mjs output
|
||||
* @param {{reviewed?: string, extraRound?: boolean, maxRounds?: number, now?: string,
|
||||
* base?: {sha256: string, text: string}|null, baseNote?: string, planPaths?: string[]}} [opts]
|
||||
* base?: {sha256: string, text: string}|null, baseNote?: string, planPaths?: string[],
|
||||
* threatModel?: {usable: boolean, why: string|null, threatModel: object}, briefPath?: string}} [opts]
|
||||
* reviewed = planHash of the text the reviewers read (from --hash);
|
||||
* base = the operative text the previous round reviewed, with its sha256;
|
||||
* baseNote = why there is no base (one line, written as diff_note);
|
||||
* planPaths = the absolute forms of the plan path (a finding whose
|
||||
* resolve(file) is one of them cites the plan). Without base, baseNote and
|
||||
* planPaths the round is recorded as before, plus the rule-B record.
|
||||
* @returns {{code: 0|1|2, text?: string, verdict?: object, lowered?: number, message: string}}
|
||||
* threatModel = lib/review/threat-model.mjs `usable(brief)` for --brief;
|
||||
* briefPath = the --brief path, stored as `brief_path`.
|
||||
* @returns {{code: 0|1|2, text?: string, verdict?: object, lowered?: number,
|
||||
* threat_model?: {applied: boolean, why: string|null}, message: string}}
|
||||
*/
|
||||
export function recordVerdict(text, dedup, opts = {}) {
|
||||
if (!dedup || typeof dedup !== 'object' || !Array.isArray(dedup.findings)) {
|
||||
|
|
@ -355,6 +399,17 @@ export function recordVerdict(text, dedup, opts = {}) {
|
|||
}
|
||||
const extra = prevExtra + (capped ? 1 : 0);
|
||||
const diffMode = opts.base !== undefined || opts.baseNote !== undefined || opts.planPaths !== undefined;
|
||||
// The threat model (E 2.4): a backed finding leaves the counted set (and so
|
||||
// the stop rule) before countRound and is listed in known_outside.
|
||||
const use = opts.threatModel;
|
||||
const tm = use && use.usable === true ? use.threatModel : null;
|
||||
const backed = [];
|
||||
const counted = [];
|
||||
for (const f of dedup.findings) {
|
||||
const line = tm ? backedLine(f, tm) : null;
|
||||
if (line !== null) backed.push({ id: f.id ?? null, rule_key: f.rule_key ?? null, text: typeof f.text === 'string' ? f.text : '', line });
|
||||
else counted.push(f);
|
||||
}
|
||||
const c = countRound({
|
||||
round,
|
||||
previous: prev.verdict ?? null,
|
||||
|
|
@ -362,7 +417,7 @@ export function recordVerdict(text, dedup, opts = {}) {
|
|||
baseNote: opts.baseNote ?? null,
|
||||
current: { text: lines.text, sha256: current, fullToOp: lines.fullToOp, opToFull: lines.opToFull },
|
||||
planPaths: Array.isArray(opts.planPaths) ? opts.planPaths : [],
|
||||
findings: dedup.findings,
|
||||
findings: counted,
|
||||
marks: Array.isArray(dedup.marks) ? dedup.marks : [],
|
||||
});
|
||||
const findings = c.findings;
|
||||
|
|
@ -382,15 +437,32 @@ export function recordVerdict(text, dedup, opts = {}) {
|
|||
verdict.counts = counts;
|
||||
verdict.findings = findings;
|
||||
if (c.resolved !== null) verdict.resolved = c.resolved;
|
||||
// known_outside: this round's backed findings, after the previous round's
|
||||
// entries that no reviewer marked resolved and that are not reported again
|
||||
// under their id (backed again: listed once; counted: in full, not listed).
|
||||
const marks = Array.isArray(dedup.marks) ? dedup.marks : [];
|
||||
const backedIds = new Set(backed.map(e => e.id).filter(id => id !== null));
|
||||
const countedIds = new Set(findings.map(x => x.id).filter(id => typeof id === 'string'));
|
||||
const kept = (Array.isArray(prev.verdict?.known_outside) ? prev.verdict.known_outside : [])
|
||||
.filter(e => !knownResolved(e, marks) && !backedIds.has(e.id) && !countedIds.has(e.id));
|
||||
const knownOutside = [...kept, ...backed];
|
||||
if (knownOutside.length > 0) verdict.known_outside = knownOutside;
|
||||
const briefPath = typeof opts.briefPath === 'string' && opts.briefPath !== '' ? opts.briefPath : prev.verdict?.brief_path;
|
||||
if (briefPath !== undefined) verdict.brief_path = briefPath;
|
||||
// The stop rule's history: this round's summary after the carried ones. A
|
||||
// round with a diff_note (or, outside diff mode, any round from 2 on) was
|
||||
// counted in full, so "new" is not known: newByClass null.
|
||||
const history = Array.isArray(prev.verdict?.rounds) ? prev.verdict.rounds : [];
|
||||
verdict.rounds = [...history, { round, ...roundSummary(findings, { fullCount: c.diffNote !== null }) }];
|
||||
verdict.stop_rule = decideStop(verdict.rounds);
|
||||
const threat = use === undefined
|
||||
? { applied: false, why: 'no --brief given' }
|
||||
: { applied: use.usable === true, why: use.usable === true ? null : (use.why ?? 'the threat model is not usable') };
|
||||
return {
|
||||
code: 0, text: placeSection(text, renderSection(verdict)), verdict, lowered: c.lowered,
|
||||
code: 0, text: placeSection(text, renderSection(verdict)), verdict, lowered: c.lowered, threat_model: threat,
|
||||
message: `round ${round} recorded: ${verdict.verdict}` +
|
||||
(knownOutside.length > 0 ? `; ${knownOutside.length} finding(s) outside the threat model listed as known issues` : '') +
|
||||
(use !== undefined && !threat.applied ? `; threat model not applied, every security finding counts: ${threat.why}` : '') +
|
||||
(verdict.stop_rule.fired ? `; PLAN_GATE_STOP_RULE: ${describeStop(verdict.stop_rule)}` : ''),
|
||||
};
|
||||
}
|
||||
|
|
@ -465,11 +537,44 @@ export function acceptOpen(rawText, opts = {}) {
|
|||
return { code: 0, text: placeSection(text, renderSection(accepted)), message: `${majors.length} open MAJOR(s) of round ${v.round} accepted` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check each known_outside entry against the brief (E 2.4): backed -> a
|
||||
* warning; not backed -> a stop reason (it counts in full).
|
||||
* @param {object[]} entries - the verdict's known_outside
|
||||
* @param {{usable: boolean, why: string|null, threatModel: object}} use - threat-model.mjs `usable`
|
||||
*/
|
||||
function checkKnownOutside(entries, use, reasons, warnings) {
|
||||
const tm = use?.usable === true ? use.threatModel : null;
|
||||
const backed = [];
|
||||
const unbacked = [];
|
||||
for (const e of entries) {
|
||||
(tm && backedLine({ rule_key: e.rule_key, threat_model_ref: e.line }, tm) !== null ? backed : unbacked).push(e);
|
||||
}
|
||||
if (backed.length > 0) {
|
||||
warnings.push({
|
||||
code: 'PLAN_GATE_KNOWN_OUTSIDE',
|
||||
message: `${backed.length} security finding(s) outside the brief's threat model, listed as known issues: ${backed.map(e => `${e.text} (Outside: ${e.line})`).join(' | ')}`,
|
||||
});
|
||||
}
|
||||
if (unbacked.length > 0) {
|
||||
const why = tm ? 'no `Outside:` line in the brief equals it, or it is not a Voyage security finding' : (use?.why ?? 'the threat model is not usable');
|
||||
reasons.push({
|
||||
code: 'PLAN_GATE_OUTSIDE_NOT_BACKED',
|
||||
message: `${unbacked.length} finding(s) recorded as outside the threat model are not backed by the brief now (${why}), so they count in full: ${unbacked.map(e => e.text).join(' | ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* K3 — decide whether the plan may go on.
|
||||
* @param {string} rawText - the plan
|
||||
* @param {{threatModel?: {usable: boolean, why: string|null, threatModel: object}}} [opts]
|
||||
* threatModel = the brief's threat model as threat-model.mjs `usable` gives it;
|
||||
* absent, the gate reads the brief at the verdict's `brief_path` itself
|
||||
* (only when the verdict lists `known_outside`).
|
||||
* @returns {{code: 0|1, status: 'pass'|'stop', reasons: object[], warnings: object[], verdict: object|null, stop_rule: object|null, validation: object}}
|
||||
*/
|
||||
export function gatePlan(rawText) {
|
||||
export function gatePlan(rawText, opts = {}) {
|
||||
const text = norm(rawText);
|
||||
const reasons = [];
|
||||
const warnings = [];
|
||||
|
|
@ -524,6 +629,13 @@ export function gatePlan(rawText) {
|
|||
reasons.push({ code: 'PLAN_GATE_ROUNDS_EXHAUSTED', message: `All ${v.round} critic rounds are spent with a BLOCKER open: stop and ask the operator.` });
|
||||
}
|
||||
}
|
||||
const known = Array.isArray(v.known_outside) ? v.known_outside : [];
|
||||
if (known.length > 0) {
|
||||
const use = opts.threatModel ?? (typeof v.brief_path === 'string'
|
||||
? threatModelUsable(v.brief_path)
|
||||
: { usable: false, why: 'the verdict names no brief_path', threatModel: null });
|
||||
checkKnownOutside(known, use, reasons, warnings);
|
||||
}
|
||||
if (!stop && majors.length > 0) {
|
||||
const exhausted = v.round >= v.max_rounds + (v.extra_rounds ?? 0);
|
||||
const list = majors.map(m => m.text).join(' | ');
|
||||
|
|
@ -656,14 +768,19 @@ function diffReport(text, dir) {
|
|||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --diff <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json | --accept-open <plan.md>';
|
||||
const known = new Set(['--json', '--hash', '--diff', '--record', '--reviewed', '--extra-round', '--accept-open']);
|
||||
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --diff <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] [--brief <brief.md>] < dedup.json | --accept-open <plan.md>';
|
||||
const known = new Set(['--json', '--hash', '--diff', '--record', '--reviewed', '--extra-round', '--accept-open', '--brief']);
|
||||
const rIdx = args.indexOf('--reviewed');
|
||||
const reviewed = rIdx >= 0 ? args[rIdx + 1] : undefined;
|
||||
const positional = args.filter((a, i) => !a.startsWith('--') && !(rIdx >= 0 && i === rIdx + 1));
|
||||
// --brief takes a value, kept out of the positional arguments like --reviewed's.
|
||||
const bIdx = args.indexOf('--brief');
|
||||
const briefArg = bIdx >= 0 ? args[bIdx + 1] : undefined;
|
||||
const valueAt = i => (rIdx >= 0 && i === rIdx + 1) || (bIdx >= 0 && i === bIdx + 1);
|
||||
const positional = args.filter((a, i) => !a.startsWith('--') && !valueAt(i));
|
||||
const unknown = args.filter(a => a.startsWith('--') && !known.has(a));
|
||||
const planPath = positional[0];
|
||||
if (!planPath || positional.length > 1 || unknown.length > 0 || (rIdx >= 0 && (!reviewed || reviewed.startsWith('--')))) {
|
||||
if (!planPath || positional.length > 1 || unknown.length > 0 || (rIdx >= 0 && (!reviewed || reviewed.startsWith('--'))) ||
|
||||
(bIdx >= 0 && (!briefArg || briefArg.startsWith('--')))) {
|
||||
process.stderr.write(`${usage}\n`);
|
||||
out({ code: 2, status: 'error', message: unknown.length ? `unknown argument: ${unknown.join(' ')}` : 'bad arguments' });
|
||||
process.exit(2);
|
||||
|
|
@ -708,6 +825,10 @@ if (isMain(import.meta.url)) {
|
|||
}
|
||||
const dir = snapshotDir(planPath);
|
||||
const opts = { extraRound: args.includes('--extra-round'), reviewed, planPaths: planPathsOf(planPath) };
|
||||
if (briefArg !== undefined) {
|
||||
opts.briefPath = briefArg;
|
||||
opts.threatModel = threatModelUsable(briefArg);
|
||||
}
|
||||
const lb = loadBase(text, dir);
|
||||
if (lb.verdict) {
|
||||
opts.base = lb.base;
|
||||
|
|
@ -735,6 +856,7 @@ if (isMain(import.meta.url)) {
|
|||
diff_base: r.verdict?.diff_base ?? null, diff_note: r.verdict?.diff_note ?? null,
|
||||
lowered: r.code === 0 ? r.lowered : null, snapshot,
|
||||
stop_rule: r.verdict?.stop_rule ?? r.stop_rule ?? null, reason: r.reason ?? null,
|
||||
known_outside: r.verdict?.known_outside?.length ?? 0, threat_model: r.threat_model ?? null,
|
||||
});
|
||||
process.exit(r.code);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,16 @@ for (const rel of AGENTS) {
|
|||
assert.ok(!read(rel).includes(ENGINES_KEY), `${rel} must not name ${ENGINES_KEY}: it is the engines' key only`);
|
||||
});
|
||||
|
||||
test(`${rel}: a security finding needs a concrete attack path and high confidence, and quotes the Outside: line in threat_model_ref (E 2.4)`, () => {
|
||||
const t = norm(read(rel));
|
||||
assert.ok(t.includes('concrete attack path'), `${rel} must ask for a concrete attack path`);
|
||||
assert.match(t, /high confidence[^.]*Claude Code's (own )?threshold/, `${rel} must name the high-confidence threshold (167)`);
|
||||
assert.match(t, /`Outside:`[^.]*`threat_model_ref`|`threat_model_ref`[^.]*`Outside:`/, `${rel} must say the Outside: line goes in threat_model_ref`);
|
||||
assert.match(t, /exactly/, `${rel} must say the line is quoted exactly`);
|
||||
const tpl = jsonTemplate(read(rel), rel);
|
||||
assert.ok('threat_model_ref' in tpl.findings[0], `${rel}: the json template carries threat_model_ref`);
|
||||
});
|
||||
|
||||
test(`${rel}: [STP] rule_key names the class, never the instance (the stop rule's class, decision 217)`, () => {
|
||||
const t = norm(read(rel));
|
||||
assert.ok(t.includes('`rule_key` names the class, never the instance'), `${rel} must say rule_key names the class, never the instance`);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,18 @@ test('Phase 9 is a bounded loop: --record after the dedup, the cap equals MAX_RO
|
|||
assert.match(p9, /Kiro/, 'the bounded loop is borrowed and credited');
|
||||
});
|
||||
|
||||
test('Phase 9 hands the brief to plan-critic and --brief to --record; the threat model\'s known issues are named (E 2.4)', () => {
|
||||
const p9 = phase(PLAN_CMD, 9);
|
||||
const critic = p9.slice(p9.indexOf('**plan-critic**'), p9.indexOf('**scope-guardian**'));
|
||||
assert.match(critic, /Brief file: \{brief_path\}/, 'plan-critic gets the brief path');
|
||||
assert.match(critic, /## Threat Model/);
|
||||
assert.match(p9, /--record "\{plan_path\}" --reviewed \{the hash from --hash\} --brief "\{brief_path\}"/);
|
||||
assert.match(p9, /`known_outside`/);
|
||||
assert.match(p9, /`threat_model`/);
|
||||
assert.match(p9, /PLAN_GATE_KNOWN_OUTSIDE/);
|
||||
assert.match(p9, /PLAN_GATE_OUTSIDE_NOT_BACKED/);
|
||||
});
|
||||
|
||||
test('Phase 10 runs the plan gate before offering "execute", and covers plan mode', () => {
|
||||
const p10 = phase(PLAN_CMD, 10);
|
||||
assert.ok(p10.indexOf('plan-gate.mjs --json') < p10.indexOf('Say **"execute"**'), 'the gate runs before the offer');
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -479,3 +479,54 @@ test('anchor rule: the same content on another line is not merged by the anchor
|
|||
const noLine = dedupFindings(pair([{ ...CRITIC_248, line: 0 }], [{ ...GUARDIAN_248, line: 0 }]));
|
||||
assert.equal(noLine.findings.length, 2, 'line 0 is file-scoped, not an anchor');
|
||||
});
|
||||
|
||||
// ---- E 2.4: threat_model_ref through the merge; a null finding no longer crashes it ----
|
||||
|
||||
const REF = "a local user who can already edit the repo's hooks";
|
||||
const SEC = 'A hook file edited by a local user runs before the parser checks its input at all';
|
||||
|
||||
test('threat_model_ref survives dedup on a single finding', () => {
|
||||
const r = dedupFindings([{ agent: 'plan-critic', payload: { findings: [
|
||||
{ file: 'plan.md', line: 9, rule_key: 'SECURITY_RISK', severity: 'blocker', text: SEC, threat_model_ref: REF },
|
||||
] } }]);
|
||||
assert.equal(r.findings[0].threat_model_ref, REF);
|
||||
});
|
||||
|
||||
test('a merge keeps threat_model_ref only when every merged copy carries the same one', () => {
|
||||
const merge = (a, b) => dedupFindings([
|
||||
{ agent: 'plan-critic', payload: { findings: [{ file: 'plan.md', line: 9, rule_key: 'SECURITY_RISK', severity: 'blocker', text: SEC, ...a }] } },
|
||||
{ agent: 'scope-guardian', payload: { findings: [{ file: 'plan.md', line: 9, rule_key: 'SECURITY_RISK', severity: 'blocker', text: SEC, ...b }] } },
|
||||
]);
|
||||
const same = merge({ threat_model_ref: REF }, { threat_model_ref: REF });
|
||||
assert.equal(same.findings.length, 1);
|
||||
assert.equal(same.findings[0].threat_model_ref, REF);
|
||||
for (const [a, b] of [
|
||||
[{ threat_model_ref: REF }, {}],
|
||||
[{}, { threat_model_ref: REF }],
|
||||
[{ threat_model_ref: REF }, { threat_model_ref: 'anyone at all' }],
|
||||
]) {
|
||||
const r = merge(a, b);
|
||||
assert.equal(r.findings.length, 1);
|
||||
assert.equal('threat_model_ref' in r.findings[0], false, JSON.stringify({ a, b }));
|
||||
}
|
||||
});
|
||||
|
||||
test('a null in a reviewer\'s findings list no longer crashes dedup (the S160 known issue)', () => {
|
||||
let r;
|
||||
assert.doesNotThrow(() => {
|
||||
r = dedupFindings([{ agent: 'plan-critic', payload: { findings: [null, { file: 'plan.md', line: 3, rule_key: 'PC1', severity: 'major', text: 't' }] } }]);
|
||||
});
|
||||
assert.equal(r.findings.length, 1);
|
||||
assert.equal(r.findings[0].rule_key, 'PC1');
|
||||
});
|
||||
|
||||
test('CLI --stdin: a null finding no longer crashes it (exit 0)', () => {
|
||||
const r = spawnSync(process.execPath, [SHIM, '--stdin'], {
|
||||
input: JSON.stringify({ plan_critic: { findings: [null] }, scope_guardian: { findings: [] } }),
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
assert.deepEqual(parsed.findings, []);
|
||||
assert.deepEqual(parsed.sources.map(s => s.present), [true, true]);
|
||||
});
|
||||
|
|
|
|||
347
tests/validators/plan-gate-threat-model.test.mjs
Normal file
347
tests/validators/plan-gate-threat-model.test.mjs
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
// tests/validators/plan-gate-threat-model.test.mjs
|
||||
// Part 2, step 2.4 of the E plan (decisions 164, 218, 220): the brief's threat
|
||||
// model on the plan side (lib/validators/plan-gate.mjs, the reader in
|
||||
// lib/review/threat-model.mjs).
|
||||
//
|
||||
// - `--record --brief <path>` applies the threat model to the deduped findings
|
||||
// before countRound: a backed finding (Voyage's own security finding whose
|
||||
// `threat_model_ref` equals an `Outside:` line of a usable threat model)
|
||||
// leaves the counted set and the stop rule and goes to the verdict's
|
||||
// `known_outside` (id, rule_key, text, line), carried until marked
|
||||
// resolved; the verdict stores `brief_path`. When the threat model is not
|
||||
// usable every security finding counts, and the output says why.
|
||||
// - The bare gate re-checks each entry against the brief: backed -> WARN
|
||||
// (PLAN_GATE_KNOWN_OUTSIDE); not backed -> STOP
|
||||
// (PLAN_GATE_OUTSIDE_NOT_BACKED, it counts in full).
|
||||
// - extractVerdict shape-checks both new fields.
|
||||
//
|
||||
// The record cases follow tests/validators/plan-gate-stop-rule.test.mjs; the
|
||||
// CLI case runs the gate as a child in its own temp `git init` repo with a
|
||||
// stamped and logged brief.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync, realpathSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gatePlan, recordVerdict, planHash, operativeText, extractVerdict } from '../../lib/validators/plan-gate.mjs';
|
||||
import { dedupFindings } from '../../lib/review/plan-review-dedup.mjs';
|
||||
import { readThreatModel } from '../../lib/review/threat-model.mjs';
|
||||
import { stampIntentApproval } from '../../lib/validators/intent-approval.mjs';
|
||||
import { appendApproval, normalizeHex, normalizeSubject, resolveRepo } from '../../lib/util/approval-log.mjs';
|
||||
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const NOW = '2026-11-08T00:00:00.000Z';
|
||||
const PLAN = resolve('plan.md');
|
||||
const PATHS = [PLAN];
|
||||
|
||||
const OUTSIDE = "a local user who can already edit the repo's hooks";
|
||||
const INSIDE = 'a crafted file name in a pull request reaches the shell';
|
||||
const BRIEF = `---
|
||||
type: trekbrief
|
||||
brief_version: "2.2"
|
||||
created: 2026-11-08
|
||||
task: "Add a parser CLI"
|
||||
slug: parser-cli
|
||||
project_dir: .claude/projects/2026-11-08-parser-cli/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
framing: new-direction
|
||||
---
|
||||
|
||||
# Task
|
||||
|
||||
## TL;DR
|
||||
|
||||
A parser CLI.
|
||||
|
||||
## Intent
|
||||
|
||||
The CLI reads files from strangers.
|
||||
|
||||
## Goal
|
||||
|
||||
A parser CLI that checks its input.
|
||||
|
||||
## Threat Model
|
||||
|
||||
- Inside: ${INSIDE}
|
||||
- Outside: ${OUTSIDE}
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- It parses.
|
||||
`;
|
||||
const TM = { usable: true, why: null, threatModel: readThreatModel(BRIEF) };
|
||||
const NOT_USABLE = { usable: false, why: 'the intent approval is not current (BRIEF_INTENT_APPROVAL_STALE)', threatModel: readThreatModel(BRIEF) };
|
||||
|
||||
function at(text, s) {
|
||||
const n = text.split('\n').findIndex(l => l.includes(s)) + 1;
|
||||
assert.ok(n > 0, `the plan holds ${JSON.stringify(s)}`);
|
||||
return n;
|
||||
}
|
||||
const baseOf = t => ({ sha256: planHash(t), text: operativeText(t) });
|
||||
function rec(text, d, opts = {}) {
|
||||
const r = recordVerdict(text, d, { reviewed: planHash(text), planPaths: PATHS, now: NOW, ...opts });
|
||||
assert.equal(r.code, 0, r.message);
|
||||
return r;
|
||||
}
|
||||
function dd({ pc = [], sg = [], pcPrev, sgPrev } = {}) {
|
||||
return dedupFindings([
|
||||
{ agent: 'plan-critic', payload: { findings: pc, ...(pcPrev ? { previous: pcPrev } : {}) } },
|
||||
{ agent: 'scope-guardian', payload: { findings: sg, ...(sgPrev ? { previous: sgPrev } : {}) } },
|
||||
]);
|
||||
}
|
||||
const f = (severity, line, text, rule_key, extra = {}) => ({ file: 'plan.md', line, rule_key, severity, text, ...extra });
|
||||
|
||||
const R1_CHANGES = 'Add `run1()`';
|
||||
const R2_CHANGES = '- **Changes:** Add `run2()` to `src/mod2.mjs`. (new file)';
|
||||
const R2_EDITED = '- **Changes:** Add `run2()` and its error path to `src/mod2.mjs`. (new file)\n - `run2()` throws on empty input.';
|
||||
const editA = t => { assert.ok(t.includes(R2_CHANGES)); return t.replace(R2_CHANGES, R2_EDITED); };
|
||||
|
||||
const S1 = 'A hook file edited by a local user runs before the parser checks its input';
|
||||
const B1 = 'The dependency on the parser module is never declared in the manifest list';
|
||||
|
||||
/** Round 1: one backed SECURITY_RISK blocker (outside) and nothing else. */
|
||||
function backedRound1(extra = {}) {
|
||||
const t1 = buildPlan();
|
||||
return rec(t1, dd({ pc: [f('blocker', at(t1, R1_CHANGES), S1, 'SECURITY_RISK', { threat_model_ref: OUTSIDE })] }),
|
||||
{ threatModel: TM, briefPath: 'brief.md', ...extra });
|
||||
}
|
||||
|
||||
// ---- recordVerdict --------------------------------------------------------------------
|
||||
|
||||
test('record: a backed security finding leaves the counted set and goes to known_outside; brief_path is stored', () => {
|
||||
const r = backedRound1();
|
||||
const v = r.verdict;
|
||||
assert.deepEqual(v.counts, { blocker: 0, major: 0, minor: 0 });
|
||||
assert.equal(v.verdict, 'PASS');
|
||||
assert.deepEqual(v.findings, []);
|
||||
assert.equal(v.known_outside?.length, 1, JSON.stringify(v));
|
||||
const e = v.known_outside[0];
|
||||
assert.deepEqual(Object.keys(e).sort(), ['id', 'line', 'rule_key', 'text']);
|
||||
assert.match(e.id, /^[0-9a-f]{40}$/);
|
||||
assert.equal(e.rule_key, 'SECURITY_RISK');
|
||||
assert.equal(e.text, S1);
|
||||
assert.equal(e.line, OUTSIDE);
|
||||
assert.equal(v.brief_path, 'brief.md');
|
||||
assert.equal(r.threat_model?.applied, true);
|
||||
// It leaves the stop rule too: the round's summary has no blocking finding.
|
||||
assert.deepEqual(v.rounds, [{ round: 1, blocking: 0, newByClass: {} }]);
|
||||
// The written block reads back with both fields.
|
||||
const ex = extractVerdict(r.text);
|
||||
assert.equal(ex.error, null);
|
||||
assert.deepEqual(ex.verdict.known_outside, v.known_outside);
|
||||
});
|
||||
|
||||
test('record: not backed (no ref, an Inside line, an engine finding, or a threat model that is not usable) -> it counts', () => {
|
||||
const t1 = buildPlan();
|
||||
const l = at(t1, R1_CHANGES);
|
||||
const cases = [
|
||||
[f('blocker', l, S1, 'SECURITY_RISK'), TM],
|
||||
[f('blocker', l, S1, 'SECURITY_RISK', { threat_model_ref: INSIDE }), TM],
|
||||
[f('blocker', l, S1, 'SECURITY_REVIEW_VULNERABILITY', { threat_model_ref: OUTSIDE }), TM],
|
||||
[f('blocker', l, S1, 'SECURITY_RISK', { threat_model_ref: OUTSIDE }), NOT_USABLE],
|
||||
[f('blocker', l, S1, 'SECURITY_RISK', { threat_model_ref: OUTSIDE }), undefined],
|
||||
];
|
||||
for (const [finding, threatModel] of cases) {
|
||||
const r = rec(t1, dd({ pc: [finding] }), { threatModel });
|
||||
assert.equal(r.verdict.counts.blocker, 1, JSON.stringify(finding));
|
||||
assert.equal(r.verdict.known_outside, undefined);
|
||||
assert.equal(r.verdict.verdict, 'BLOCK');
|
||||
}
|
||||
const r = rec(t1, dd({ pc: [cases[3][0]] }), { threatModel: NOT_USABLE, briefPath: 'brief.md' });
|
||||
assert.equal(r.threat_model?.applied, false);
|
||||
assert.match(String(r.threat_model?.why), /BRIEF_INTENT_APPROVAL_STALE/, 'the output says why every security finding counts');
|
||||
});
|
||||
|
||||
test('record: a backed finding beside a counted one: only the counted one is blocking in the stop rule', () => {
|
||||
const t1 = buildPlan();
|
||||
const l = at(t1, R1_CHANGES);
|
||||
const r1 = rec(t1, dd({ pc: [f('blocker', l, B1, 'dep/manifest'), f('blocker', l, S1, 'SECURITY_RISK', { threat_model_ref: OUTSIDE })] }),
|
||||
{ threatModel: TM, briefPath: 'brief.md' });
|
||||
assert.equal(r1.verdict.counts.blocker, 1);
|
||||
assert.deepEqual(r1.verdict.rounds, [{ round: 1, blocking: 1, newByClass: { dep: 1 } }]);
|
||||
assert.equal(r1.verdict.known_outside?.length, 1);
|
||||
});
|
||||
|
||||
test('record: known_outside is carried to the next round until marked resolved; brief_path is carried without --brief', () => {
|
||||
const r1 = backedRound1();
|
||||
assert.equal(r1.verdict.known_outside?.length, 1, 'round 1 lists the backed finding');
|
||||
const id = r1.verdict.known_outside[0].id;
|
||||
const t2 = editA(r1.text);
|
||||
// Round 2, no --brief, nothing re-reported, no mark: carried, and brief_path with it.
|
||||
const r2 = rec(t2, dd(), { base: baseOf(r1.text) });
|
||||
assert.deepEqual(r2.verdict.known_outside, r1.verdict.known_outside);
|
||||
assert.equal(r2.verdict.brief_path, 'brief.md');
|
||||
// A mark resolved: false keeps it; resolved: true drops it.
|
||||
const kept = rec(t2, dd({ pcPrev: [{ id, resolved: false }] }), { base: baseOf(r1.text) });
|
||||
assert.equal(kept.verdict.known_outside?.length, 1);
|
||||
const gone = rec(t2, dd({ pcPrev: [{ id, resolved: true }] }), { base: baseOf(r1.text) });
|
||||
assert.equal(gone.verdict.known_outside, undefined);
|
||||
});
|
||||
|
||||
test('record: re-reported without its backing it counts in full and leaves known_outside; re-reported backed it is listed once', () => {
|
||||
const r1 = backedRound1();
|
||||
const t2 = editA(r1.text);
|
||||
const l = at(t2, R1_CHANGES);
|
||||
const counted = rec(t2, dd({ pc: [f('blocker', l, S1, 'SECURITY_RISK')] }), { base: baseOf(r1.text), threatModel: TM, briefPath: 'brief.md' });
|
||||
assert.equal(counted.verdict.counts.blocker, 1);
|
||||
assert.equal(counted.verdict.known_outside, undefined, 'the same id counts now; it is not also a known issue');
|
||||
const again = rec(t2, dd({ pc: [f('blocker', l, S1, 'SECURITY_RISK', { threat_model_ref: OUTSIDE })] }),
|
||||
{ base: baseOf(r1.text), threatModel: TM, briefPath: 'brief.md' });
|
||||
assert.equal(again.verdict.known_outside?.length, 1);
|
||||
assert.equal(again.verdict.counts.blocker, 0);
|
||||
});
|
||||
|
||||
test('extractVerdict shape-checks known_outside and brief_path', () => {
|
||||
const r = backedRound1();
|
||||
const v = extractVerdict(r.text).verdict;
|
||||
const put = obj => r.text.replace(/```json\n[\s\S]*?\n```/, () => '```json\n' + JSON.stringify(obj, null, 2) + '\n```');
|
||||
for (const bad of [
|
||||
{ ...v, known_outside: 'x' },
|
||||
{ ...v, known_outside: [null] },
|
||||
{ ...v, known_outside: [{ id: 'a', rule_key: 'SECURITY_RISK', text: 'x' }] },
|
||||
{ ...v, known_outside: [{ id: 'a', rule_key: 'SECURITY_RISK', text: 'x', line: '' }] },
|
||||
{ ...v, known_outside: [{ id: 7, rule_key: 'SECURITY_RISK', text: 'x', line: 'y' }] },
|
||||
{ ...v, brief_path: '' },
|
||||
{ ...v, brief_path: 3 },
|
||||
]) {
|
||||
assert.ok(extractVerdict(put(bad)).error, `malformed: ${JSON.stringify({ known_outside: bad.known_outside, brief_path: bad.brief_path })}`);
|
||||
}
|
||||
assert.equal(extractVerdict(put(v)).error, null);
|
||||
});
|
||||
|
||||
// ---- the bare gate ------------------------------------------------------------------
|
||||
|
||||
test('gate: a backed entry is a WARN (PLAN_GATE_KNOWN_OUTSIDE) and the plan may go on', () => {
|
||||
const r = backedRound1();
|
||||
const g = gatePlan(r.text, { threatModel: TM });
|
||||
assert.equal(g.code, 0, JSON.stringify(g.reasons));
|
||||
const w = g.warnings.find(x => x.code === 'PLAN_GATE_KNOWN_OUTSIDE');
|
||||
assert.ok(w, JSON.stringify(g.warnings));
|
||||
assert.ok(w.message.includes(S1) && w.message.includes(OUTSIDE), w.message);
|
||||
});
|
||||
|
||||
test('gate: an entry not backed now (line gone, or the threat model not usable) STOPs with PLAN_GATE_OUTSIDE_NOT_BACKED', () => {
|
||||
const r = backedRound1();
|
||||
const gone = { usable: true, why: null, threatModel: readThreatModel(BRIEF.replace(OUTSIDE, 'anyone at all')) };
|
||||
for (const [tm, why] of [[gone, /no `?Outside:`? line/], [NOT_USABLE, /BRIEF_INTENT_APPROVAL_STALE/]]) {
|
||||
const g = gatePlan(r.text, { threatModel: tm });
|
||||
assert.equal(g.code, 1);
|
||||
const s = g.reasons.find(x => x.code === 'PLAN_GATE_OUTSIDE_NOT_BACKED');
|
||||
assert.ok(s, JSON.stringify(g.reasons));
|
||||
assert.ok(s.message.includes(S1), s.message);
|
||||
assert.match(s.message, /counts? in full/);
|
||||
assert.match(s.message, why);
|
||||
assert.ok(!g.warnings.some(x => x.code === 'PLAN_GATE_KNOWN_OUTSIDE'));
|
||||
}
|
||||
});
|
||||
|
||||
test('gate guard: a planted known_outside entry that is not a Voyage security finding STOPs', () => {
|
||||
const r = backedRound1();
|
||||
assert.equal(r.verdict.known_outside?.length, 1, 'round 1 lists the backed finding');
|
||||
const planted = r.text.replace(/```json\n[\s\S]*?\n```/, () => {
|
||||
const v = extractVerdict(r.text).verdict;
|
||||
return '```json\n' + JSON.stringify({ ...v, known_outside: [{ ...v.known_outside[0], rule_key: 'MISSING_TEST' }] }, null, 2) + '\n```';
|
||||
});
|
||||
assert.equal(extractVerdict(planted).error, null);
|
||||
const g = gatePlan(planted, { threatModel: TM });
|
||||
assert.equal(g.code, 1);
|
||||
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_OUTSIDE_NOT_BACKED'), JSON.stringify(g.reasons));
|
||||
});
|
||||
|
||||
test('gate: without --brief the brief is read from the verdict\'s brief_path; none named -> not backed', () => {
|
||||
const r = backedRound1({ briefPath: undefined });
|
||||
assert.equal(r.verdict.brief_path, undefined);
|
||||
const g = gatePlan(r.text);
|
||||
assert.equal(g.code, 1);
|
||||
const s = g.reasons.find(x => x.code === 'PLAN_GATE_OUTSIDE_NOT_BACKED');
|
||||
assert.ok(s, JSON.stringify(g.reasons));
|
||||
assert.match(s.message, /brief_path/);
|
||||
});
|
||||
|
||||
// ---- CLI ------------------------------------------------------------------------------
|
||||
|
||||
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const GATE = join(ROOT, 'lib', 'validators', 'plan-gate.mjs');
|
||||
const GIT_CLEAN = { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined };
|
||||
const run = (cwd, args, input) => {
|
||||
const r = spawnSync(process.execPath, [GATE, ...args], { cwd, input, encoding: 'utf-8', env: childEnv(GIT_CLEAN) });
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
};
|
||||
const lastJson = out => JSON.parse(out.split('\n').filter(l => l !== '').at(-1));
|
||||
|
||||
test('CLI: --record --brief lists a backed finding as a known issue; the gate warns, and STOPs once the Outside line changes', () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'plan-gate-tm-')));
|
||||
try {
|
||||
assert.equal(spawnSync('git', ['init', '-q'], { cwd: dir, env: childEnv(GIT_CLEAN) }).status, 0);
|
||||
const b = join(dir, 'brief.md');
|
||||
const s = stampIntentApproval(BRIEF, new Date(NOW));
|
||||
assert.equal(s.stamped, true);
|
||||
writeFileSync(b, s.text);
|
||||
const repo = resolveRepo(dir);
|
||||
assert.equal(appendApproval({ repo, kind: 'intent', subject: normalizeSubject(repo, b), sha: normalizeHex(s.hash) }).appended, true);
|
||||
const p = join(dir, 'plan.md');
|
||||
writeFileSync(p, buildPlan());
|
||||
const t1 = readFileSync(p, 'utf-8');
|
||||
const payload = dd({ pc: [{ ...f('blocker', at(t1, R1_CHANGES), S1, 'SECURITY_RISK', { threat_model_ref: OUTSIDE }), file: p }] });
|
||||
const c1 = run(dir, ['--record', p, '--brief', 'brief.md', '--reviewed', planHash(t1)], JSON.stringify(payload));
|
||||
assert.equal(c1.code, 0, c1.out + c1.err);
|
||||
const j1 = lastJson(c1.out);
|
||||
assert.equal(j1.verdict, 'PASS');
|
||||
assert.equal(j1.known_outside, 1);
|
||||
assert.deepEqual(j1.threat_model, { applied: true, why: null });
|
||||
const v = extractVerdict(readFileSync(p, 'utf-8')).verdict;
|
||||
assert.equal(v.brief_path, 'brief.md');
|
||||
|
||||
const g = run(dir, [p]);
|
||||
assert.equal(g.code, 0, g.out);
|
||||
assert.match(g.out, /WARN {2}\[PLAN_GATE_KNOWN_OUTSIDE\]/);
|
||||
|
||||
// The operator lifts the finding back in: the Outside line changes (the approval goes stale too).
|
||||
writeFileSync(b, readFileSync(b, 'utf-8').replace(OUTSIDE, 'anyone at all'));
|
||||
const g2 = run(dir, [p, '--json']);
|
||||
assert.equal(g2.code, 1, g2.out);
|
||||
const j2 = JSON.parse(g2.out);
|
||||
assert.ok(j2.reasons.some(x => x.code === 'PLAN_GATE_OUTSIDE_NOT_BACKED'), g2.out);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI: --brief without a value is a usage error; a threat model that is not usable is said in the output', () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'plan-gate-tm-')));
|
||||
try {
|
||||
assert.equal(spawnSync('git', ['init', '-q'], { cwd: dir, env: childEnv(GIT_CLEAN) }).status, 0);
|
||||
const p = join(dir, 'plan.md');
|
||||
writeFileSync(p, buildPlan());
|
||||
const t1 = readFileSync(p, 'utf-8');
|
||||
const bare = run(dir, ['--record', p, '--reviewed', planHash(t1), '--brief'], JSON.stringify(dd()));
|
||||
assert.equal(bare.code, 2, bare.out + bare.err);
|
||||
// An unapproved brief: the finding counts, and the output says why.
|
||||
writeFileSync(join(dir, 'brief.md'), BRIEF);
|
||||
const payload = dd({ pc: [{ ...f('blocker', at(t1, R1_CHANGES), S1, 'SECURITY_RISK', { threat_model_ref: OUTSIDE }), file: p }] });
|
||||
const c1 = run(dir, ['--record', p, '--brief', 'brief.md', '--reviewed', planHash(t1)], JSON.stringify(payload));
|
||||
assert.equal(c1.code, 0, c1.out + c1.err);
|
||||
const j1 = lastJson(c1.out);
|
||||
assert.equal(j1.verdict, 'BLOCK');
|
||||
assert.equal(j1.known_outside, 0);
|
||||
assert.equal(j1.threat_model.applied, false);
|
||||
assert.match(j1.threat_model.why, /BRIEF_INTENT_NOT_APPROVED/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Handover 4 ---------------------------------------------------------------------
|
||||
|
||||
test('Handover 4 names --brief, known_outside, brief_path and the two new codes', () => {
|
||||
const h = readFileSync(join(ROOT, 'docs', 'HANDOVER-CONTRACTS.md'), 'utf-8');
|
||||
for (const s of ['`--brief <brief.md>`', '`known_outside`', '`brief_path`', '`PLAN_GATE_KNOWN_OUTSIDE`', '`PLAN_GATE_OUTSIDE_NOT_BACKED`']) {
|
||||
assert.ok(h.includes(s), `docs/HANDOVER-CONTRACTS.md names ${s}`);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue