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

@ -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`);

View file

@ -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');

View file

@ -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]);
});

View 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}`);
}
});