feat(review): the security engine chosen and Claude Security converted (E 3.1)

lib/review/security-engine.mjs (decisions 175, 196, 219):
- choose: Claude Security when available and base..head has committed
  changes; else /security-review when origin/HEAD resolves and
  origin/HEAD...HEAD is non-empty; else no engine (no-engine, no-origin,
  empty-diff), which is "not measured", never 0. A git failure or bad
  usage is exit 2, never a reason.
- convert: reads the newest CLAUDE-SECURITY-<ts>/ report whose stamp is a
  changes scan of exactly base..head; confidence high becomes engine
  findings through toReviewerPayload, the rest go to below_threshold and
  never into findings. An unverified scan, missing researcher counts,
  fewer researchers returned than dispatched, an empty diff or scope, or
  a malformed record is exit 2.
- NO_ENGINE_REVIEWER: with no engine chosen the expected reviewer
  security-engine never reports, so the verdict gate exits 2.

ENGINES gains claude-security (its own reviewer and brief_ref, the
engines' key) with a per-engine label for the written action; it is a
security engine in isSecurityFinding, RAN_ENGINES, the coordinator
contract (out-of-scope and brief-conflict rules) and the coordinator
prose; engine:below-threshold is an unverified reason fed like
engineOutOfScope. Pins of the changed coordinator prose follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-08 13:29:59 +01:00
commit cb7c13b408
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
18 changed files with 1045 additions and 44 deletions

View file

@ -0,0 +1,318 @@
// lib/review/security-engine.mjs
// The security engine of /trekreview (E 3.1; decisions 175, 196, 219).
//
// Which engine reviews security, and Claude Security's own report turned into
// a reviewer payload. Security that no engine could look at is "not
// measured", never 0 (196; S203: `/security-review` gave 0 in 13 of 13).
//
// choose --repo <dir> --base <sha> --head <sha> --claude-security available|missing
// prints {engine, reason}. "available" is decided by the orchestrator (the
// `claude-security:claude-security` skill is listed and the Workflow tool is
// callable) and passed in; this module never probes for it.
// 1. Claude Security available and `base..head` has committed changes
// -> engine "claude-security", reason "claude-security-available".
// 2. else, when origin's HEAD resolves (`refs/remotes/origin/HEAD`):
// origin/HEAD...HEAD has changes -> "security-review" (reason
// "claude-security-missing", or "claude-security-empty-diff" when Claude
// Security was available but its range was empty); none -> null,
// "empty-diff".
// 3. else null: "empty-diff" when Claude Security was available (its range
// was empty), "no-engine" without an `origin` remote (neither engine can
// run), "no-origin" with an `origin` remote whose HEAD does not resolve.
// Engine null is security not measured: /trekreview then expects the
// reviewer NO_ENGINE_REVIEWER (`security-engine`), which never reports, so
// the review carries `missing-reviewer:security-engine` and the verdict gate
// exits 2. Only committed changes count (uncommitted edits are not in either
// engine's range).
// exit 0 = decided (the engine may be null); 2 = could not run: bad usage,
// not a git repository, a sha that does not resolve, a git failure. Never a
// reason, never a guess.
//
// convert --report-dir <dir> --base <sha> --head <sha> [--scope <file-list.txt>]
// <dir> is the scanned repo's root, where a changes scan writes its
// `CLAUDE-SECURITY-<UTC YYYYMMDD-HHMMSS>/` folder (Claude Security 0.12.0,
// scripts/render_report.py). Reads the newest such folder whose one stamp
// `CLAUDE-SECURITY-REVISION-<12 hex>[-dirty].json` has mode "changes",
// revision.commit == head and revision.merge_base == base (a `-dirty` stamp
// counts; a folder with no stamp or two is passed over), and its
// `CLAUDE-SECURITY-RESULTS.jsonl`, one finding per line. Each record becomes
// an engine finding through engine-findings.mjs `toReviewerPayload` (engine
// `claude-security`): file <- the stamp's `scan_prefix` + `file` (the record
// is relative to the scan root), summary <- title, failure_scenario <-
// exploit_scenario, category <- cwe_id, fix <- recommendation; no narrower
// rule_key. Confidence `high` -> `findings` / `out_of_scope` as for any
// engine; every other confidence -> `below_threshold`, never in `findings`,
// so engine-compare.mjs `putBack` and the verdict gate never restore one
// (219: listed under Declined to Judge as `engine:below-threshold`, an
// unverified reason: not counted, it keeps the verdict from ALLOW). No hand
// transcription.
// Prints {reviewer, findings, out_of_scope, below_threshold, report}; exit 0.
// Not measured (exit 2, the reason on stderr, nothing on stdout): no such
// stamp (an empty diff writes none), verification.status not "verified",
// researchers_returned or researchers_dispatched missing or returned <
// dispatched, run_shape.empty_diff or empty_scope true or not recorded, a
// malformed scan_prefix, a missing or unreadable JSONL, a record that does not
// parse or lacks its file, title or a confidence of low|medium|high.
// exit 1 = the scope list is empty (as engine-findings.mjs); bad usage = 2.
//
// The report folder lives in the repo under review, so its text is data: a
// stamp counts only when it names this exact head and merge base, and a
// record's text is carried, never followed.
import { spawnSync } from 'node:child_process';
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
import { join, posix } from 'node:path';
import { toReviewerPayload, ENGINES } from './engine-findings.mjs';
import { FULL_SHA_RE } from './reviewed-commit.mjs';
import { isMain } from '../util/is-main.mjs';
/** The reviewer /trekreview expects when no security engine was chosen: it never reports. */
export const NO_ENGINE_REVIEWER = 'security-engine';
const ENGINE = 'claude-security';
const REPORT_DIR_RE = /^CLAUDE-SECURITY-[0-9][0-9-]*$/;
const STAMP_RE = /^CLAUDE-SECURITY-REVISION-.+\.json$/;
const JSONL_NAME = 'CLAUDE-SECURITY-RESULTS.jsonl';
const CONFIDENCES = new Set(['low', 'medium', 'high']);
const ORIGIN_HEAD = 'refs/remotes/origin/HEAD';
const isPlainObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
const couldNotRun = (reason) => ({ exit: 2, reason });
const notMeasured = (why) => ({ exit: 2, reason: `Claude Security report not measured: ${why}` });
// ---- choose ------------------------------------------------------------------------
function git(repo, args) {
return spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
}
/** true = the two commits differ, false = no change, null = git failed. Committed trees only. */
function differs(repo, ...revs) {
const r = git(repo, ['diff', '--quiet', '--no-ext-diff', '--no-textconv', ...revs, '--']);
if (r.error) return null;
if (r.status === 0) return false;
if (r.status === 1) return true;
return null;
}
/**
* Which security engine reviews `base..head` (see the header).
* @param {{ repo: string, base: string, head: string, claudeSecurity: 'available'|'missing' }} args
* @returns {{ exit: 0, engine: string|null, reason: string } | { exit: 2, reason: string }}
*/
export function chooseEngine({ repo, base, head, claudeSecurity } = {}) {
if (typeof repo !== 'string' || repo === '') return couldNotRun('--repo is required');
if (!FULL_SHA_RE.test(String(base ?? ''))) return couldNotRun('--base must be a full 40- or 64-hex commit id');
if (!FULL_SHA_RE.test(String(head ?? ''))) return couldNotRun('--head must be a full 40- or 64-hex commit id');
if (claudeSecurity !== 'available' && claudeSecurity !== 'missing') {
return couldNotRun('--claude-security must be available or missing');
}
const top = git(repo, ['rev-parse', '--git-dir']);
if (top.error || top.status !== 0) return couldNotRun(`${repo} is not a git repository`);
for (const [name, sha] of [['--base', base], ['--head', head]]) {
const r = git(repo, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]);
if (r.error || r.status !== 0) return couldNotRun(`${name} ${sha} does not resolve to a commit`);
}
const available = claudeSecurity === 'available';
if (available) {
const changed = differs(repo, base, head);
if (changed === null) return couldNotRun(`git diff ${base}..${head} failed`);
if (changed) return { exit: 0, engine: ENGINE, reason: 'claude-security-available' };
}
const originHead = git(repo, ['rev-parse', '--verify', '--quiet', ORIGIN_HEAD]);
if (originHead.error || (originHead.status !== 0 && originHead.status !== 1)) {
return couldNotRun(`git rev-parse ${ORIGIN_HEAD} failed`);
}
if (originHead.status === 0) {
const changed = differs(repo, `${ORIGIN_HEAD}...HEAD`);
if (changed === null) return couldNotRun(`git diff ${ORIGIN_HEAD}...HEAD failed`);
if (!changed) return { exit: 0, engine: null, reason: 'empty-diff' };
return { exit: 0, engine: 'security-review', reason: available ? 'claude-security-empty-diff' : 'claude-security-missing' };
}
if (available) return { exit: 0, engine: null, reason: 'empty-diff' };
const remotes = git(repo, ['remote']);
if (remotes.error || remotes.status !== 0) return couldNotRun('git remote failed');
const hasOrigin = remotes.stdout.split('\n').map((l) => l.trim()).includes('origin');
return { exit: 0, engine: null, reason: hasOrigin ? 'no-origin' : 'no-engine' };
}
// ---- convert -----------------------------------------------------------------------
const isDir = (p) => { try { return lstatSync(p).isDirectory(); } catch { return false; } };
const isFile = (p) => { try { return lstatSync(p).isFile(); } catch { return false; } };
/** The parsed stamp of a report folder when it holds exactly one regular stamp file, else null. */
function stampIn(folder) {
let names;
try { names = readdirSync(folder); } catch { return null; }
const stamps = names.filter((n) => STAMP_RE.test(n) && isFile(join(folder, n)));
if (stamps.length !== 1) return null;
try {
const stamp = JSON.parse(readFileSync(join(folder, stamps[0]), 'utf8'));
return isPlainObject(stamp) ? { name: stamps[0], stamp } : null;
} catch { return null; }
}
const sameSha = (a, b) => typeof a === 'string' && a.toLowerCase() === b.toLowerCase();
/** The newest report folder in `dir` whose stamp is a changes scan of exactly base..head, or null. */
export function findReport(dir, base, head) {
let names;
try { names = readdirSync(dir); } catch { return null; }
const folders = names.filter((n) => REPORT_DIR_RE.test(n) && isDir(join(dir, n))).sort().reverse();
for (const name of folders) {
const s = stampIn(join(dir, name));
if (!s) continue;
const rev = isPlainObject(s.stamp.revision) ? s.stamp.revision : {};
if (s.stamp.mode === 'changes' && sameSha(rev.commit, head) && sameSha(rev.merge_base, base)) {
return { folder: join(dir, name), name, stampName: s.name, stamp: s.stamp };
}
}
return null;
}
/** Why a matching stamp cannot be trusted as a full scan, or null. */
function stampProblem(stamp) {
const v = isPlainObject(stamp.verification) ? stamp.verification : {};
if (v.status !== 'verified') return `verification.status is ${JSON.stringify(v.status ?? null)}, not "verified"`;
const dispatched = v.researchers_dispatched;
const returned = v.researchers_returned;
if (!Number.isInteger(dispatched) || !Number.isInteger(returned)) {
return 'the stamp does not record how many researchers were dispatched and returned';
}
if (returned < dispatched) return `${returned} of ${dispatched} researchers returned`;
const shape = isPlainObject(stamp.run_shape) ? stamp.run_shape : {};
if (typeof shape.empty_diff !== 'boolean' || typeof shape.empty_scope !== 'boolean') {
return 'the stamp does not record whether the diff or the scope was empty';
}
if (shape.empty_diff) return 'the scan saw an empty diff';
if (shape.empty_scope) return 'the scan saw an empty scope';
const prefix = stamp.scan_prefix;
if (typeof prefix !== 'string' || (prefix !== '' && (!prefix.endsWith('/') || prefix.startsWith('/')
|| prefix.includes('\\') || prefix.split('/').slice(0, -1).some((seg) => seg === '' || seg === '.' || seg === '..')))) {
return `scan_prefix ${JSON.stringify(prefix ?? null)} is not a path prefix`;
}
return null;
}
/** The JSONL records as engine-findings input with their confidence, or a reason. */
function readRecords(folder, prefix) {
const p = join(folder, JSONL_NAME);
if (!isFile(p)) return { reason: `${JSONL_NAME} is missing` };
let text;
try { text = readFileSync(p, 'utf8'); } catch (e) { return { reason: `${JSONL_NAME} cannot be read: ${e.message}` }; }
const out = [];
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '') continue;
let r;
try { r = JSON.parse(lines[i]); } catch (e) { return { reason: `${JSONL_NAME} line ${i + 1} does not parse: ${e.message}` }; }
if (!isPlainObject(r)) return { reason: `${JSONL_NAME} line ${i + 1} is not an object` };
const confidence = String(r.confidence ?? '').trim().toLowerCase();
if (typeof r.file !== 'string' || r.file.trim() === '') return { reason: `${JSONL_NAME} line ${i + 1} has no file` };
if (typeof r.title !== 'string' || r.title.trim() === '') return { reason: `${JSONL_NAME} line ${i + 1} has no title` };
if (!CONFIDENCES.has(confidence)) {
return { reason: `${JSONL_NAME} line ${i + 1} has confidence ${JSON.stringify(r.confidence ?? null)}, not low|medium|high` };
}
const str = (v) => (typeof v === 'string' ? v : '');
out.push({
confidence,
finding: {
file: posix.normalize(prefix + r.file.trim()),
line: r.line,
summary: r.title,
failure_scenario: str(r.exploit_scenario),
category: str(r.cwe_id),
...(str(r.recommendation).trim() !== '' ? { fix: r.recommendation } : {}),
},
});
}
return { records: out };
}
/**
* Claude Security's report of `base..head` as a reviewer payload (see the header).
* @param {{ reportDir: string, base: string, head: string, scopeFiles?: string[] }} args
* @returns {{ exit: 0, payload: object } | { exit: 1|2, reason: string }}
*/
export function convertReport({ reportDir, base, head, scopeFiles } = {}) {
if (typeof reportDir !== 'string' || reportDir === '') return couldNotRun('--report-dir is required');
if (!FULL_SHA_RE.test(String(base ?? ''))) return couldNotRun('--base must be a full 40- or 64-hex commit id');
if (!FULL_SHA_RE.test(String(head ?? ''))) return couldNotRun('--head must be a full 40- or 64-hex commit id');
const found = findReport(reportDir, base, head);
if (!found) {
return notMeasured(`no CLAUDE-SECURITY-<ts>/ folder in ${reportDir} holds one stamp of a changes scan of ${base}..${head} (an empty diff writes none)`);
}
const problem = stampProblem(found.stamp);
if (problem) return notMeasured(`${found.name}/${found.stampName}: ${problem}`);
const read = readRecords(found.folder, found.stamp.scan_prefix);
if (read.reason) return notMeasured(`${found.name}: ${read.reason}`);
const high = read.records.filter((r) => r.confidence === 'high').map((r) => r.finding);
const rest = read.records.filter((r) => r.confidence !== 'high').map((r) => r.finding);
const counted = toReviewerPayload({ engine: ENGINE, findings: high }, { scopeFiles });
if (!counted.ok) return { exit: 1, reason: counted.errors.join('; ') };
const below = toReviewerPayload({ engine: ENGINE, findings: rest });
if (!below.ok) return notMeasured(`${found.name}: ${below.errors.join('; ')}`);
return {
exit: 0,
payload: {
reviewer: ENGINES[ENGINE].reviewer,
findings: counted.payload.findings,
out_of_scope: counted.out_of_scope,
below_threshold: below.payload.findings,
report: { folder: found.name, stamp: found.stampName },
},
};
}
// ---- CLI ---------------------------------------------------------------------------
const USAGE = 'Usage: security-engine.mjs choose --repo <dir> --base <sha> --head <sha> --claude-security available|missing\n'
+ ' security-engine.mjs convert --report-dir <dir> --base <sha> --head <sha> [--scope <file-list.txt>]\n';
/** `--flag value` pairs, each known flag at most once; null on anything else. */
function flags(args, known) {
const out = {};
for (let i = 0; i < args.length; i += 2) {
const k = args[i];
if (!known.includes(k) || Object.hasOwn(out, k) || i + 1 >= args.length) return null;
out[k] = args[i + 1];
}
return out;
}
function runCli(argv) {
const [cmd, ...rest] = argv;
if (cmd === 'choose') {
const f = flags(rest, ['--repo', '--base', '--head', '--claude-security']);
if (!f) return couldNotRun('bad arguments');
const r = chooseEngine({ repo: f['--repo'], base: f['--base'], head: f['--head'], claudeSecurity: f['--claude-security'] });
return r.exit === 0 ? { exit: 0, out: { engine: r.engine, reason: r.reason } } : r;
}
if (cmd === 'convert') {
const f = flags(rest, ['--report-dir', '--base', '--head', '--scope']);
if (!f) return couldNotRun('bad arguments');
let scopeFiles;
if (f['--scope'] !== undefined) {
if (!existsSync(f['--scope'])) return couldNotRun(`--scope ${f['--scope']} does not exist`);
try {
scopeFiles = readFileSync(f['--scope'], 'utf8').split('\n').map((l) => l.trim()).filter(Boolean);
} catch (e) { return couldNotRun(`cannot read --scope: ${e.message}`); }
}
const r = convertReport({ reportDir: f['--report-dir'], base: f['--base'], head: f['--head'], scopeFiles });
return r.exit === 0 ? { exit: 0, out: r.payload } : r;
}
return couldNotRun('a subcommand is required: choose or convert');
}
if (isMain(import.meta.url)) {
let r;
try { r = runCli(process.argv.slice(2)); } catch (e) { r = couldNotRun(`security-engine crashed: ${e.message}`); }
if (r.exit === 0) {
process.stdout.write(`${JSON.stringify(r.out, null, 2)}\n`);
} else {
process.stderr.write(`security-engine: ${r.reason}\n`);
if (r.reason === 'bad arguments' || r.reason.startsWith('a subcommand')) process.stderr.write(USAGE);
}
process.exit(r.exit);
}