fix(gate): part 2 verifier findings — non-object findings, carried backed copy, rule_keys, marks, approval prose, brief_path, engine put-back once
1. plan-review-dedup skips only a null or undefined finding; any other entry that is not an object (a string, a number, an array) goes on as before E, a finding with no valid severity that the plan gate counts as a blocker (it was dropped, so a string blocker recorded PASS). 2. plan-gate --record: a previous finding that rule A carried as counted and whose id is backed this round leaves the counted set, the counts, the verdict and the stop rule's summary (it was counted and listed in known_outside at once: BLOCK, not-fewer, and --accept-open refused). 3. A known_outside entry keeps rule_keys; extractVerdict accepts it (a list of strings), and the bare gate re-checks with the same keys the recorder used, so a merged finding whose security key is only in rule_keys is a WARN, not a STOP. 4. plan-critic, scope-guardian and /trekplan Phase 9's round-2 sentences say the Critic Verdict also lists known_outside and each is marked by id in previous. Code already accepts such a mark (dedup passes it; countRound ignores ids it does not carry; --record removes the entry on true); pinned in diff mode and full count. 5. The approval prose names ## Threat Model where it named only ## Intent + ## Goal: skills/annotating/SKILL.md (description, step 4, guard list), docs/command-modes.md, Handover 1's intent_approved_hash row, README (approval, Approve row, intent approval gate) and CLAUDE.md's /trekbrief row. 6. threat-model.mjs gains usableInRepo (a brief path relative to the repo top level) and repoRelativeBrief; --record stores brief_path relative to the top level, never absolute, and both gates read it from there, so the gate run from a subdirectory reads the same brief. reviewThreatModel uses usableInRepo. Handover 4 and the gate header say so. 7. review-finalize's outside pass puts an entry back only when no finding carries its row yet (engine-compare rowOf): an engine finding listed in known_outside while its payload is saved went back twice. 8. A test runs scripts/release-check.mjs on a review whose known_outside entry the brief does not back: exit 2 (control: backed passes the review check). Existing tests changed: plan-gate-threat-model's entry-keys assertion now expects rule_keys (item 3), and its planted non-security entry drops the security key from rule_keys too, since the gate reads rule_keys (item 3). Part 2 fix round of the E plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c47061191c
commit
1be961aab1
17 changed files with 298 additions and 52 deletions
|
|
@ -84,6 +84,12 @@ for (const rel of AGENTS) {
|
|||
assert.ok(t.includes('`rule_key` names the class, never the instance'), `${rel} must say rule_key names the class, never the instance`);
|
||||
});
|
||||
|
||||
test(`${rel}: round 2+ marks each known_outside entry by id in previous too (E fix round)`, () => {
|
||||
const t = norm(read(rel));
|
||||
assert.match(t, /`## Critic Verdict` also lists `known_outside`[^.]*\. Mark each of them by id in `previous` the same way/);
|
||||
assert.match(t, /`previous` \(from round 2; omit it in round 1\) holds one `\{id, resolved\}` per finding the current `## Critic Verdict` lists, its `known_outside` entries included/);
|
||||
});
|
||||
|
||||
test(`${rel}: round 2+ lists every previous finding by id and cites the exact line`, () => {
|
||||
const t = norm(read(rel));
|
||||
assert.ok(t.includes('From round 2 the prompt gives you the diff since the last reviewed text'), `${rel}: round-2 diff sentence missing`);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ test('Phase 9 hands the brief to plan-critic and --brief to --record; the threat
|
|||
assert.match(p9, /`threat_model`/);
|
||||
assert.match(p9, /PLAN_GATE_KNOWN_OUTSIDE/);
|
||||
assert.match(p9, /PLAN_GATE_OUTSIDE_NOT_BACKED/);
|
||||
// Fix round: both round-2 sentences name the known_outside entries to mark.
|
||||
const round2 = p9.split('\n').filter(l => l.startsWith('"Previous findings are in the plan\'s `## Critic Verdict`'));
|
||||
assert.equal(round2.length, 2, 'one round-2 sentence per reviewer');
|
||||
for (const l of round2) assert.match(l, /its `known_outside` entries included/);
|
||||
});
|
||||
|
||||
test('Phase 10 runs the plan gate before offering "execute", and covers plan mode', () => {
|
||||
|
|
|
|||
|
|
@ -520,6 +520,16 @@ test('a null in a reviewer\'s findings list no longer crashes dedup (the S160 kn
|
|||
assert.equal(r.findings[0].rule_key, 'PC1');
|
||||
});
|
||||
|
||||
test('only null and undefined are skipped: a string, a number or an array entry stays a finding with no valid severity, as before E', () => {
|
||||
const one = dedupFindings([{ agent: 'plan-critic', payload: { findings: [null, undefined, 'BLOCKER: rm -rf on an unquoted path'] } }]);
|
||||
assert.equal(one.findings.length, 1, JSON.stringify(one.findings));
|
||||
assert.equal(one.findings[0].severity, null, 'no valid severity: the plan gate counts it as a blocker');
|
||||
// Several such entries carry no text, so the Jaccard pass merges them, as it did before E.
|
||||
const many = dedupFindings([{ agent: 'plan-critic', payload: { findings: [7, ['x'], 'str'] } }]);
|
||||
assert.ok(many.findings.length >= 1, JSON.stringify(many.findings));
|
||||
for (const f of many.findings) assert.equal(f.severity, null);
|
||||
});
|
||||
|
||||
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: [] } }),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { validateReviewContent } from '../../lib/validators/review-validator.mjs
|
|||
import { stampIntentApproval } from '../../lib/validators/intent-approval.mjs';
|
||||
import { appendApproval, normalizeHex, normalizeSubject, resolveRepo } from '../../lib/util/approval-log.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
import { releaseCheck } from '../../scripts/release-check.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const FINALIZE = join(ROOT, 'lib', 'review', 'review-finalize.mjs');
|
||||
|
|
@ -287,6 +288,58 @@ test('gate: a backed entry turns not backed once the brief\'s approval is stale
|
|||
assert.match(JSON.parse(g2.stdout).reason, /BRIEF_INTENT_APPROVAL_STALE/);
|
||||
});
|
||||
|
||||
// ---- Part 2 fix round ---------------------------------------------------------------
|
||||
|
||||
test('fix 7: an engine finding the coordinator listed in known_outside while its payload is saved goes back once', () => {
|
||||
const fx = tmRepo();
|
||||
const engineFinding = {
|
||||
id: 'c'.repeat(40), severity: 'BLOCKER', rule_key: 'SECURITY_INJECTION', file: 'commands/up.md', line: 5,
|
||||
brief_ref: 'NFR — security (Claude Code /security-review)', title: 'Unquoted name reaches rm',
|
||||
detail: 'rm -rf "$NAME" with a name from the request.', recommended_action: 'Quote and contain the name.', engine: 'security-review',
|
||||
};
|
||||
writeFileSync(join(fx.project, '.review-engine-payload-security-review.json'),
|
||||
JSON.stringify({ reviewer: 'claude-security-review', findings: [engineFinding], out_of_scope: [] }));
|
||||
const { id: _id, ...asListed } = engineFinding;
|
||||
fx.write(tmReview({ start: fx.start, end: fx.end, known: [{ ...asListed, threat_model_ref: OUTSIDE }] }));
|
||||
const r = fx.finalize();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
const json = jsonOf(fx.read());
|
||||
const at5 = json.findings.filter((f) => f.file === 'commands/up.md' && f.line === 5);
|
||||
assert.equal(at5.length, 1, `put back once: ${JSON.stringify(json.findings.map((f) => [f.id, f.rule_key, f.engine]))}`);
|
||||
assert.equal(at5[0].severity, 'BLOCKER');
|
||||
assert.deepEqual(json.known_outside ?? [], []);
|
||||
assert.equal(json.verdict, 'BLOCK');
|
||||
const v = fx.validate();
|
||||
assert.equal(v.status, 0, v.stdout + v.stderr);
|
||||
});
|
||||
|
||||
test('fix 8: scripts/release-check.mjs exits 2 on a review whose known_outside entry the brief does not back', () => {
|
||||
const fx = tmRepo();
|
||||
fx.write(tmReview({ start: fx.start, end: fx.end, known: [entry({ threat_model_ref: INSIDE })] }));
|
||||
const r = releaseCheck({ repo: fx.repo, review: fx.reviewPath, version: '1.0.0', suiteTimeoutMs: 30000 });
|
||||
assert.equal(r.exit, 2, JSON.stringify(r.checks));
|
||||
const rev = r.checks.find((c) => c.id === 'review');
|
||||
assert.equal(rev.status, 'error');
|
||||
assert.match(rev.detail, /threat model/i);
|
||||
// Control: the same review with a backed entry passes the review check.
|
||||
fx.write(tmReview({ start: fx.start, end: fx.end, known: [entry()] }));
|
||||
const c = releaseCheck({ repo: fx.repo, review: fx.reviewPath, version: '1.0.0', suiteTimeoutMs: 30000 });
|
||||
assert.equal(c.checks.find((x) => x.id === 'review').status, 'pass', JSON.stringify(c.checks));
|
||||
});
|
||||
|
||||
test('fix 5: the approval prose says a ## Threat Model is covered too, when the brief has one', () => {
|
||||
const line = (rel, needle) => read(rel).split('\n').find((l) => l.includes(needle)) ?? '';
|
||||
assert.match(line('docs/command-modes.md', '| `--approve <project-dir'), /`## Threat Model`/);
|
||||
assert.match(line('docs/HANDOVER-CONTRACTS.md', '| `intent_approved_hash` |'), /`## Threat Model`/);
|
||||
assert.match(line('CLAUDE.md', '| `/trekbrief` |'), /`## Threat Model`/);
|
||||
assert.match(line('README.md', '**Approval, logged and committed.**'), /`## Threat Model`/);
|
||||
assert.match(line('README.md', '| **Approve** |'), /`## Threat Model`/);
|
||||
assert.match(line('README.md', '**Intent approval gate.**'), /`## Threat Model`/);
|
||||
const skill = read('skills/annotating/SKILL.md');
|
||||
assert.match(skill.split('\n').find((l) => l.startsWith('description:')), /## Threat Model/);
|
||||
assert.equal((flat(skill).match(/`## Intent` or `## Goal` \(or `## Threat Model`/g) ?? []).length, 2, 'the Process step and the guard list');
|
||||
});
|
||||
|
||||
// ---- prose --------------------------------------------------------------------------
|
||||
|
||||
function subsection(text, heading) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ 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 { mkdtempSync, mkdirSync, 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';
|
||||
|
|
@ -122,9 +122,10 @@ test('record: a backed security finding leaves the counted set and goes to known
|
|||
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.deepEqual(Object.keys(e).sort(), ['id', 'line', 'rule_key', 'rule_keys', 'text']);
|
||||
assert.match(e.id, /^[0-9a-f]{40}$/);
|
||||
assert.equal(e.rule_key, 'SECURITY_RISK');
|
||||
assert.deepEqual(e.rule_keys, ['SECURITY_RISK']);
|
||||
assert.equal(e.text, S1);
|
||||
assert.equal(e.line, OUTSIDE);
|
||||
assert.equal(v.brief_path, 'brief.md');
|
||||
|
|
@ -246,7 +247,7 @@ test('gate guard: a planted known_outside entry that is not a Voyage security fi
|
|||
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```';
|
||||
return '```json\n' + JSON.stringify({ ...v, known_outside: [{ ...v.known_outside[0], rule_key: 'MISSING_TEST', rule_keys: ['MISSING_TEST'] }] }, null, 2) + '\n```';
|
||||
});
|
||||
assert.equal(extractVerdict(planted).error, null);
|
||||
const g = gatePlan(planted, { threatModel: TM });
|
||||
|
|
@ -313,6 +314,35 @@ test('CLI: --record --brief lists a backed finding as a known issue; the gate wa
|
|||
}
|
||||
});
|
||||
|
||||
test('fix 6: brief_path is stored relative to the repo top level, and the gate run from a subdirectory reads the same brief', () => {
|
||||
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));
|
||||
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 sub = join(dir, 'sub');
|
||||
mkdirSync(sub);
|
||||
const p = join(dir, 'plan.md');
|
||||
for (const [cwd, briefArg] of [[dir, 'brief.md'], [sub, '../brief.md'], [sub, b]]) {
|
||||
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(cwd, ['--record', p, '--brief', briefArg, '--reviewed', planHash(t1)], JSON.stringify(payload));
|
||||
assert.equal(c1.code, 0, c1.out + c1.err);
|
||||
assert.equal(lastJson(c1.out).known_outside, 1, `${cwd} ${briefArg}: ${c1.out}`);
|
||||
assert.equal(extractVerdict(readFileSync(p, 'utf-8')).verdict.brief_path, 'brief.md', `stored relative to the top level (${briefArg})`);
|
||||
const g = run(sub, [p]);
|
||||
assert.equal(g.code, 0, `the gate from a subdirectory: ${g.out}`);
|
||||
assert.match(g.out, /WARN {2}\[PLAN_GATE_KNOWN_OUTSIDE\]/);
|
||||
}
|
||||
} 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 {
|
||||
|
|
@ -337,6 +367,73 @@ test('CLI: --brief without a value is a usage error; a threat model that is not
|
|||
}
|
||||
});
|
||||
|
||||
// ---- Part 2 fix round ---------------------------------------------------------------
|
||||
|
||||
test('fix 1: a string entry in a reviewer\'s findings still counts as a blocker through dedup and --record', () => {
|
||||
const t1 = buildPlan();
|
||||
const d = dedupFindings([
|
||||
{ agent: 'plan-critic', payload: { findings: ['BLOCKER: step 2 runs rm -rf on an unquoted user path'] } },
|
||||
{ agent: 'scope-guardian', payload: { findings: [] } },
|
||||
]);
|
||||
assert.equal(d.findings.length, 1, 'the string entry is kept as a finding with no valid severity');
|
||||
const r = rec(t1, d);
|
||||
assert.equal(r.verdict.counts.blocker, 1);
|
||||
assert.equal(r.verdict.verdict, 'BLOCK');
|
||||
});
|
||||
|
||||
test('fix 2: a finding counted in round 1 and backed in round 2 (same id, marked resolved: false) is listed once and counts nowhere', () => {
|
||||
const t1 = buildPlan();
|
||||
const r1 = rec(t1, dd({ pc: [f('blocker', at(t1, R1_CHANGES), S1, 'SECURITY_RISK')] }));
|
||||
const id = r1.verdict.findings[0].id;
|
||||
const t2 = editA(r1.text);
|
||||
const r2 = rec(t2, dd({
|
||||
pc: [f('blocker', at(t2, R1_CHANGES), S1, 'SECURITY_RISK', { threat_model_ref: OUTSIDE })],
|
||||
pcPrev: [{ id, resolved: false }], sgPrev: [{ id, resolved: false }],
|
||||
}), { base: baseOf(r1.text), threatModel: TM, briefPath: 'brief.md' });
|
||||
const v = r2.verdict;
|
||||
assert.deepEqual(v.findings.map(x => x.id), [], 'the carried copy left the counted set');
|
||||
assert.deepEqual(v.counts, { blocker: 0, major: 0, minor: 0 });
|
||||
assert.equal(v.verdict, 'PASS');
|
||||
assert.deepEqual((v.known_outside ?? []).map(e => e.id), [id]);
|
||||
assert.equal(v.rounds.at(-1).blocking, 0, 'it left the stop-rule summary');
|
||||
assert.equal(v.stop_rule.fired, false, JSON.stringify(v.stop_rule));
|
||||
assert.equal(gatePlan(r2.text, { threatModel: TM }).code, 0);
|
||||
});
|
||||
|
||||
test('fix 3: a merged finding whose security key is only in rule_keys is backed at record and in the bare gate alike', () => {
|
||||
const t1 = buildPlan();
|
||||
const l = at(t1, R1_CHANGES);
|
||||
const d = dd({
|
||||
pc: [f('blocker', l, S1, 'injection/shell', { threat_model_ref: OUTSIDE })],
|
||||
sg: [f('blocker', l, S1, 'SECURITY_RISK', { threat_model_ref: OUTSIDE })],
|
||||
});
|
||||
assert.equal(d.findings.length, 1, 'dedup merged the two');
|
||||
const r = rec(t1, d, { threatModel: TM, briefPath: 'brief.md' });
|
||||
assert.equal(r.verdict.known_outside?.length, 1);
|
||||
assert.deepEqual(r.verdict.known_outside[0].rule_keys, ['injection/shell', 'SECURITY_RISK']);
|
||||
const g = gatePlan(r.text, { threatModel: TM });
|
||||
assert.equal(g.code, 0, JSON.stringify(g.reasons));
|
||||
assert.ok(g.warnings.some(w => w.code === 'PLAN_GATE_KNOWN_OUTSIDE'), JSON.stringify(g.warnings));
|
||||
// extractVerdict accepts rule_keys and refuses a malformed one.
|
||||
const v = extractVerdict(r.text).verdict;
|
||||
const bad = r.text.replace(/```json\n[\s\S]*?\n```/, () => '```json\n' + JSON.stringify({ ...v, known_outside: [{ ...v.known_outside[0], rule_keys: [7] }] }, null, 2) + '\n```');
|
||||
assert.ok(extractVerdict(bad).error, 'a rule_keys that is not a list of strings is malformed');
|
||||
});
|
||||
|
||||
test('fix 4: a reviewer\'s mark on a known_outside id passes dedup and the count, and resolved: true removes the entry (diff mode and full count)', () => {
|
||||
const r1 = backedRound1();
|
||||
const id = r1.verdict.known_outside?.[0]?.id;
|
||||
assert.ok(id, 'round 1 lists the backed finding');
|
||||
const t2 = editA(r1.text);
|
||||
const d = dd({ pcPrev: [{ id, resolved: true }], sgPrev: [{ id, resolved: true }] });
|
||||
assert.deepEqual(d.marks, [{ agent: 'plan-critic', id, resolved: true }, { agent: 'scope-guardian', id, resolved: true }]);
|
||||
for (const opts of [{ base: baseOf(r1.text) }, { baseNote: 'no snapshot; full counting' }]) {
|
||||
const r2 = recordVerdict(t2, d, { reviewed: planHash(t2), planPaths: PATHS, now: NOW, ...opts });
|
||||
assert.equal(r2.code, 0, r2.message);
|
||||
assert.equal(r2.verdict.known_outside, undefined, JSON.stringify(opts));
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Handover 4 ---------------------------------------------------------------------
|
||||
|
||||
test('Handover 4 names --brief, known_outside, brief_path and the two new codes', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue