fix(review): the review's fix round - engines' key in rule_key, --brief only with --record, the gate's threat model from the review's work tree
The one narrow fix round after /code-review high on E (three findings of new classes; the between-rounds findings are known issues, decision 226): - threat-model.mjs isOwnSecurityFinding: the engines' key in rule_key is never Voyage's own, also when rule_keys names a key of its own (keysOf reads rule_keys alone when it is a list, so an entry with rule_key SECURITY_REVIEW_VULNERABILITY and rule_keys [SECURITY_RISK] could be backed, against decision 218). - plan-gate.mjs: --brief without --record is a usage error (exit 2); the bare gate and --accept-open read the verdict's brief_path, and a --brief there was silently ignored. - verdict-gate.mjs: the known_outside check reads the threat model from the answers' repo (--repo, else the review file's own work tree); with no work tree nothing is backed. A review outside any work tree, gated from another repo, was backed by that repo's brief (ALLOW). Each with a test seen failing on an assertion first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f820081bdc
commit
d402b0088b
6 changed files with 73 additions and 5 deletions
|
|
@ -158,7 +158,9 @@ export function isKnownEntry(e) {
|
|||
export function isOwnSecurityFinding(f) {
|
||||
if (!f || typeof f !== 'object') return false;
|
||||
if (typeof f.engine === 'string' && f.engine.trim() !== '') return false;
|
||||
if (keysOf(f).some((k) => ENGINE_SECURITY_KEYS.has(k.trim().toUpperCase()))) return false;
|
||||
// The engines' key in rule_key or in rule_keys: keysOf reads rule_keys alone when it is a list.
|
||||
const named = [f.rule_key, ...(Array.isArray(f.rule_keys) ? f.rule_keys : [])].filter((k) => typeof k === 'string');
|
||||
if (named.some((k) => ENGINE_SECURITY_KEYS.has(k.trim().toUpperCase()))) return false;
|
||||
return isSecurity(f);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ import { illegalBriefLowering, NOTED_REASONS } from './coordinator-contract.mjs'
|
|||
import { reviewedSha, checkReviewedCommit } from './reviewed-commit.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
import { resolveRepo } from '../util/approval-log.mjs';
|
||||
import { answerRecords, archivedReader, decideAnswers, isSecurityFinding, reviewSubject } from './review-answers.mjs';
|
||||
import { answerLogRepo, answerRecords, archivedReader, decideAnswers, isSecurityFinding, reviewSubject } from './review-answers.mjs';
|
||||
import { preExistingCheck, scopeShas } from './pre-existing.mjs';
|
||||
import { frontmatterField, runId } from './review-rounds.mjs';
|
||||
// No cycle: neither module, nor any module they import, imports this gate.
|
||||
|
|
@ -324,7 +324,8 @@ export function droppedEngineFindings(text, reviewPath) {
|
|||
* `Outside:` line gone, or a threat model that is not usable. An unreadable
|
||||
* trailing block gives none (decideVerdict judges it).
|
||||
* @param {string} text - review.md
|
||||
* @param {string|undefined} repo - the repo's top level
|
||||
* @param {string|null|undefined} repo - the repo's top level; none (no work
|
||||
* tree for the review) backs nothing, never the working directory's repo
|
||||
* @returns {{entries: Array<*>, why: string|null}}
|
||||
*/
|
||||
export function notBackedOutside(text, repo) {
|
||||
|
|
@ -333,8 +334,9 @@ export function notBackedOutside(text, repo) {
|
|||
const list = payload.known_outside;
|
||||
if (!Array.isArray(list)) return { entries: [list], why: 'known_outside is not a list' };
|
||||
if (list.length === 0) return { entries: [], why: null };
|
||||
const use = repo ? reviewThreatModel(text, repo) : { usable: false, why: 'no git work tree for the review', threatModel: null };
|
||||
// A malformed entry is checked as no finding, so it is never backed.
|
||||
const { unbacked, why } = splitBacked(list, reviewThreatModel(text, repo), (e) => (isKnownEntry(e) ? e : null));
|
||||
const { unbacked, why } = splitBacked(list, use, (e) => (isKnownEntry(e) ? e : null));
|
||||
return { entries: unbacked, why };
|
||||
}
|
||||
|
||||
|
|
@ -371,7 +373,9 @@ export function decideOpenFindings(text, { repo, reviewPath } = {}) {
|
|||
fixed_pending: [],
|
||||
};
|
||||
}
|
||||
const outside = notBackedOutside(text, repo);
|
||||
// The threat model is read from the answers' repo: --repo, else the review
|
||||
// file's own work tree, never the working directory's (none: nothing is backed).
|
||||
const outside = notBackedOutside(text, repo ?? (reviewPath ? answerLogRepo(dirname(reviewPath)) : null));
|
||||
if (outside.entries.length > 0) {
|
||||
const what = (e) => (e !== null && typeof e === 'object'
|
||||
? `${field(e.title) ?? '(no title)'} at ${field(e.file) ?? '?'}:${field(e.line) ?? '?'}`
|
||||
|
|
|
|||
|
|
@ -787,6 +787,12 @@ if (isMain(import.meta.url)) {
|
|||
out({ code: 2, status: 'error', message: unknown.length ? `unknown argument: ${unknown.join(' ')}` : 'bad arguments' });
|
||||
process.exit(2);
|
||||
}
|
||||
if (bIdx >= 0 && !args.includes('--record')) {
|
||||
// The bare gate and --accept-open read the verdict's stored brief_path; a --brief there would be ignored.
|
||||
process.stderr.write(`${usage}\n`);
|
||||
out({ code: 2, status: 'error', message: "--brief is read only with --record; the gate reads the verdict's brief_path" });
|
||||
process.exit(2);
|
||||
}
|
||||
let text;
|
||||
try {
|
||||
if (!existsSync(planPath)) throw new Error('file not found');
|
||||
|
|
|
|||
|
|
@ -288,6 +288,31 @@ 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/);
|
||||
});
|
||||
|
||||
test('gate: the threat model is read from the review\'s own work tree, never the working directory\'s (review fix)', () => {
|
||||
const a = tmRepo();
|
||||
const b = tmRepo();
|
||||
// A's brief edited after its approval: A's threat model is not usable, so the entry is not backed.
|
||||
writeFileSync(a.briefPath, readFileSync(a.briefPath, 'utf8').replace(INSIDE, 'a crafted name'));
|
||||
a.write(tmReview({ start: a.start, end: a.end, known: [entry()] }));
|
||||
const gateIn = (cwd, reviewPath) => spawnSync(process.execPath, [GATE, '--json', reviewPath], { cwd, encoding: 'utf8', env: childEnv() });
|
||||
// Inside A's work tree, run from B (whose brief at the same path is approved and backs the entry): A's brief decides.
|
||||
const fromB = gateIn(b.repo, a.reviewPath);
|
||||
assert.equal(fromB.status, 2, `run from another repo, A's stale brief decides: ${fromB.stdout}${fromB.stderr}`);
|
||||
assert.match(JSON.parse(fromB.stdout).reason, /BRIEF_INTENT_APPROVAL_STALE/);
|
||||
// A review outside any work tree, run from B: no work tree for the review, so nothing is backed (never B's brief).
|
||||
const loose = mkdtempSync(join(tmpdir(), 'review-tm-loose-'));
|
||||
made.push(loose);
|
||||
const loosePath = join(loose, 'review.md');
|
||||
writeFileSync(loosePath, a.read());
|
||||
const outside = gateIn(b.repo, loosePath);
|
||||
assert.equal(outside.status, 2, `a review outside any work tree is never backed by the working directory's brief: ${outside.stdout}${outside.stderr}`);
|
||||
assert.match(JSON.parse(outside.stdout).reason, /threat model|work tree/i);
|
||||
// Control: B's own review is backed by B's brief, run from anywhere inside B's tree.
|
||||
b.write(tmReview({ start: b.start, end: b.end, known: [entry()] }));
|
||||
const own = gateIn(b.repo, b.reviewPath);
|
||||
assert.equal(own.status, 0, own.stdout + own.stderr);
|
||||
});
|
||||
|
||||
// ---- Part 2 fix round ---------------------------------------------------------------
|
||||
|
||||
test('fix 7: an engine finding the coordinator listed in known_outside while its payload is saved goes back once', () => {
|
||||
|
|
|
|||
|
|
@ -187,3 +187,15 @@ test('backedLine: only a security finding not from an engine can be outside (218
|
|||
assert.equal(backedLine(notSecurity, tm), null, 'only a security finding can be outside');
|
||||
assert.equal(backedLine(own({ rule_key: 'security_injection' }), tm), OUTSIDE, 'key case is ignored, as the gate reads it');
|
||||
});
|
||||
|
||||
test('backedLine: the engines\' key in rule_key is never Voyage\'s own, also when rule_keys names a key of its own (review fix)', async () => {
|
||||
const { backedLine, readThreatModel, isOwnSecurityFinding } = await load();
|
||||
const tm = readThreatModel(brief());
|
||||
const mixed = own({ rule_key: 'SECURITY_REVIEW_VULNERABILITY', rule_keys: ['SECURITY_RISK'] });
|
||||
assert.equal(isOwnSecurityFinding(mixed), false, 'rule_key holds the engines\' key: an engine finding, whatever rule_keys says');
|
||||
assert.equal(backedLine(mixed, tm), null, 'it is never backed');
|
||||
const inList = own({ rule_key: 'SECURITY_RISK', rule_keys: ['SECURITY_RISK', 'SECURITY_REVIEW_VULNERABILITY'] });
|
||||
assert.equal(backedLine(inList, tm), null, 'the engines\' key in rule_keys is never backed either');
|
||||
// Control: Voyage's own keys in both fields stay backed.
|
||||
assert.equal(backedLine(own({ rule_key: 'SECURITY_RISK', rule_keys: ['SECURITY_RISK', 'SECURITY_INJECTION'] }), tm), OUTSIDE);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -329,6 +329,25 @@ test('CLI: --brief without a value is a usage error; a threat model that is not
|
|||
}
|
||||
});
|
||||
|
||||
test('CLI: --brief is read only with --record; on the bare gate or --accept-open it is a usage error, never ignored (review fix)', () => {
|
||||
const dir = tempRepo('plan-gate-tm-');
|
||||
try {
|
||||
const p = join(dir, 'plan.md');
|
||||
writeFileSync(p, buildPlan());
|
||||
writeFileSync(join(dir, 'brief.md'), BRIEF);
|
||||
for (const args of [[p, '--brief', 'brief.md'], [p, '--json', '--brief', 'brief.md'], ['--accept-open', p, '--brief', 'brief.md']]) {
|
||||
const r = run(dir, args);
|
||||
assert.equal(r.code, 2, `${args.join(' ')}: exit 2, got ${r.code}: ${r.out}${r.err}`);
|
||||
assert.match(r.out + r.err, /--brief is read only with --record/, `${args.join(' ')}: says why`);
|
||||
}
|
||||
// Control: the bare gate without --brief still decides (no verdict yet: STOP, exit 1).
|
||||
const bare = run(dir, [p]);
|
||||
assert.equal(bare.code, 1, bare.out + bare.err);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Part 2 fix round ---------------------------------------------------------------
|
||||
|
||||
test('fix 1: a string entry in a reviewer\'s findings still counts as a blocker through dedup and --record', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue