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:
parent
d72d3667f6
commit
cb7c13b408
18 changed files with 1045 additions and 44 deletions
|
|
@ -99,11 +99,15 @@ export const NOTED_REASONS = Object.freeze(new Set([
|
|||
* module deliberately excludes), and `accuracy:line-out-of-range` by its
|
||||
* Pass 2 Accuracy (a line past the end of the file: the citation is wrong, the
|
||||
* claim is unchecked, so a line number alone never refutes); the vocabulary is
|
||||
* owned here so prose and lib cannot drift.
|
||||
* owned here so prose and lib cannot drift. `engine:below-threshold` (E 3.1,
|
||||
* decision 219) is a Claude Security finding below confidence `high`
|
||||
* (lib/review/security-engine.mjs `below_threshold`): listed, not counted, and
|
||||
* it keeps the verdict from ALLOW.
|
||||
*/
|
||||
export const UNVERIFIED_REASONS = Object.freeze([
|
||||
'actionability:empty',
|
||||
'engine:out-of-scope',
|
||||
'engine:below-threshold',
|
||||
'unknown-rule_key',
|
||||
'file-existence:indeterminate',
|
||||
'accuracy:line-out-of-range',
|
||||
|
|
@ -117,10 +121,11 @@ export const UNVERIFIED_REASONS = Object.freeze([
|
|||
//
|
||||
// The one place that decides when a finding marked as contradicting the brief
|
||||
// (`contradicts_brief: "<the Non-Goal or stated choice, verbatim>"`) may drop
|
||||
// to MINOR. Only an engine finding (`engine` `code-review` or `security-review`)
|
||||
// is ever marked; only a `/code-review` finding at the MAJOR tier ever drops,
|
||||
// to MINOR, writing `brief_lowered_from: 'MAJOR'`. A BLOCKER and any
|
||||
// `/security-review` finding keep their severity and only carry the mark.
|
||||
// to MINOR. Only an engine finding (`engine` `code-review`, `security-review`
|
||||
// or `claude-security`) is ever marked; only a `/code-review` finding at the
|
||||
// MAJOR tier ever drops, to MINOR, writing `brief_lowered_from: 'MAJOR'`. A
|
||||
// BLOCKER and any finding from a security engine (`/security-review`, Claude
|
||||
// Security) keep their severity and only carry the mark.
|
||||
// The verdict gate (verdict-gate.mjs) imports `illegalBriefLowering`.
|
||||
// `original_severity` (what the kept finding
|
||||
// itself asserted before Pass 1 raised it, review 08deae68) is never read here.
|
||||
|
|
@ -632,7 +637,7 @@ export function computeVerdict(findings, options = {}) {
|
|||
* forbids ALLOW. Do not iterate both and count twice.
|
||||
*
|
||||
* @param {Array<{reviewer?: string, findings: object[]}>} reviewerPayloads
|
||||
* @param {{ expectedReviewers?: string[], engineOutOfScope?: object[] }} [options]
|
||||
* @param {{ expectedReviewers?: string[], engineOutOfScope?: object[], engineBelowThreshold?: object[] }} [options]
|
||||
* `declined_to_judge` lists every unverified finding with its reason, and
|
||||
* `trimmed_count` counts the MINOR/SUGGESTION findings whose text was trimmed
|
||||
* (kept, judged, and counted — never removed for length).
|
||||
|
|
@ -653,7 +658,8 @@ export function runContract(reviewerPayloads, options = {}) {
|
|||
// Engine findings on files outside the review's SHA range (engine-findings.mjs
|
||||
// `out_of_scope`). /security-review diffs against origin's default branch,
|
||||
// wider than this review: a real claim nobody here judged, so it forbids
|
||||
// ALLOW. /code-review was given exactly this range: its MINOR or SUGGESTION
|
||||
// ALLOW, and so does one from Claude Security, the other security engine.
|
||||
// /code-review was given exactly this range: its MINOR or SUGGESTION
|
||||
// finding outside it (an improvement to code the review did not change) is
|
||||
// declined and listed, never dropped, but does not hold ALLOW back
|
||||
// (voyage-lekerepo trial 2, 2026-10-08). A MAJOR+ one still forbids ALLOW:
|
||||
|
|
@ -664,7 +670,12 @@ export function runContract(reviewerPayloads, options = {}) {
|
|||
const minorOnly = (f) => f.engine === 'code-review' && (f.severity === 'MINOR' || f.severity === 'SUGGESTION');
|
||||
const outOfScopeBlocking = outOfScope.filter((f) => !minorOnly(f));
|
||||
const outOfScopeNoted = outOfScope.filter(minorOnly);
|
||||
const unverified = [...judgedUnverified, ...reasoned.unverified, ...outOfScopeBlocking];
|
||||
// Claude Security's findings below confidence `high` (security-engine.mjs
|
||||
// `below_threshold`, decision 219): never counted, listed under Declined to
|
||||
// Judge, and they keep the verdict from ALLOW, as an out-of-scope one does.
|
||||
const belowThreshold = (options.engineBelowThreshold ?? [])
|
||||
.map((f) => ({ ...f, suppressed_reason: 'engine:below-threshold' }));
|
||||
const unverified = [...judgedUnverified, ...reasoned.unverified, ...outOfScopeBlocking, ...belowThreshold];
|
||||
// What the coordinator set aside without ruling on it, one line each, so a
|
||||
// reader sees every finding that did not reach a verdict tier. Borrowed:
|
||||
// "Declined to judge … nothing you set aside is dropped silently"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@
|
|||
//
|
||||
// Only a payload's `findings` are compared. An engine finding in
|
||||
// `out_of_scope` stays declined by the existing rule
|
||||
// (lib/review/coordinator-contract.mjs runContract, `engine:out-of-scope`).
|
||||
// (lib/review/coordinator-contract.mjs runContract, `engine:out-of-scope`),
|
||||
// and so does a Claude Security finding in `below_threshold` (confidence
|
||||
// below `high`, lib/review/security-engine.mjs; `engine:below-threshold`,
|
||||
// decision 219): neither is ever put back. Claude Security's payload
|
||||
// (`reviewer` `claude-security`) maps back through ENGINES like the others.
|
||||
|
||||
import { ENGINES } from './engine-findings.mjs';
|
||||
import { JUDGE_TITLE_MAX, trimText } from './coordinator-contract.mjs';
|
||||
|
|
|
|||
|
|
@ -9,9 +9,12 @@
|
|||
// orchestrator transcribes them into a small JSON list; THIS module turns that
|
||||
// list into a reviewer payload the coordinator already understands, so the
|
||||
// engine's findings meet Voyage's brief control in the same dedup + verdict.
|
||||
// The third engine, Claude Security (E 3.1), writes its own report:
|
||||
// lib/review/security-engine.mjs `convert` reads it and calls this module, so
|
||||
// its findings are never transcribed by hand.
|
||||
//
|
||||
// Input (one engine per call):
|
||||
// { "engine": "code-review" | "security-review",
|
||||
// { "engine": "code-review" | "security-review" | "claude-security",
|
||||
// "findings": [ { "file": "...", "line": 12, "summary": "...",
|
||||
// "failure_scenario": "...", "category": "correctness",
|
||||
// "fix": "<optional: the fix the engine itself proposed>",
|
||||
|
|
@ -20,7 +23,8 @@
|
|||
// Output: { reviewer, findings: [...catalogue-conformant...], out_of_scope: [...] }
|
||||
// - rule_key: the given one when it is in the catalogue AND at least as
|
||||
// severe as the engine's own key, else CODE_REVIEW_DEFECT (MAJOR) /
|
||||
// SECURITY_REVIEW_VULNERABILITY (BLOCKER). A "narrower" key can never
|
||||
// SECURITY_REVIEW_VULNERABILITY (BLOCKER, both security engines: `security`
|
||||
// in ENGINES, SECURITY_ENGINES). A "narrower" key can never
|
||||
// lower an engine finding's tier. One exception, decided by the engine's
|
||||
// own category and not by the transcriber: a /code-review finding in an
|
||||
// IMPROVEMENT_CATEGORIES category is CODE_REVIEW_IMPROVEMENT (MINOR), since
|
||||
|
|
@ -28,8 +32,9 @@
|
|||
// MAJOR defect (voyage-lekerepo trial 2, 2026-10-08).
|
||||
// - recommended_action: the engine's own `fix` when the transcription
|
||||
// carries one; otherwise a test-first step built from the failure
|
||||
// scenario. Never the scenario dressed as a remedy (trial 2: /trekplan
|
||||
// would have taken failure scenarios as step goals).
|
||||
// scenario, or, without one, naming the engine by its own `label`. Never
|
||||
// the scenario dressed as a remedy (trial 2: /trekplan would have taken
|
||||
// failure scenarios as step goals).
|
||||
// - File paths are made repo-relative (an absolute path under `repoRoot`
|
||||
// loses that prefix) before they are compared with the scope.
|
||||
// - A finding on a file outside the review scope is NOT dropped silently: it
|
||||
|
|
@ -37,7 +42,8 @@
|
|||
// Judge (`engine:out-of-scope`). It forbids ALLOW unless it is a MINOR or
|
||||
// SUGGESTION from /code-review (coordinator-contract.mjs runContract);
|
||||
// `/security-review` diffs against origin's default branch, which can be
|
||||
// wider than the review's SHA range.
|
||||
// wider than the review's SHA range. Claude Security scans exactly that
|
||||
// range; one of its findings outside the scope forbids ALLOW all the same.
|
||||
// - An empty scope list is an error, never "everything is out of scope".
|
||||
//
|
||||
// CLI: node lib/review/engine-findings.mjs [--scope <file-list.txt>] [--repo-root <dir>] <engine-findings.json>
|
||||
|
|
@ -49,19 +55,36 @@ import { RULE_KEYS, getRule, SEVERITY_VALUES } from './rule-catalogue.mjs';
|
|||
import { validateFindings } from './findings-schema.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
// `label` names the engine in a written action; `security` marks a security
|
||||
// engine (a finding from it is a security finding, review-rounds.mjs
|
||||
// `isSecurityFinding`).
|
||||
export const ENGINES = Object.freeze({
|
||||
'code-review': Object.freeze({
|
||||
reviewer: 'claude-code-review',
|
||||
rule_key: 'CODE_REVIEW_DEFECT',
|
||||
brief_ref: 'NFR — code correctness (Claude Code /code-review)',
|
||||
label: '/code-review',
|
||||
security: false,
|
||||
}),
|
||||
'security-review': Object.freeze({
|
||||
reviewer: 'claude-security-review',
|
||||
rule_key: 'SECURITY_REVIEW_VULNERABILITY',
|
||||
brief_ref: 'NFR — security (Claude Code /security-review)',
|
||||
label: '/security-review',
|
||||
security: true,
|
||||
}),
|
||||
'claude-security': Object.freeze({
|
||||
reviewer: 'claude-security',
|
||||
rule_key: 'SECURITY_REVIEW_VULNERABILITY',
|
||||
brief_ref: 'NFR — security (Claude Security)',
|
||||
label: 'Claude Security',
|
||||
security: true,
|
||||
}),
|
||||
});
|
||||
|
||||
/** The security engines: `/security-review` and Claude Security. */
|
||||
export const SECURITY_ENGINES = Object.freeze(Object.keys(ENGINES).filter((e) => ENGINES[e].security));
|
||||
|
||||
function normPath(p, repoRoot) {
|
||||
let out = String(p ?? '').trim().replace(/\\/g, '/');
|
||||
if (repoRoot) {
|
||||
|
|
@ -132,7 +155,7 @@ export function toReviewerPayload(input, opts = {}) {
|
|||
recommended_action: fix
|
||||
|| (scenario
|
||||
? `Write a test that reproduces this at ${loc} (${scenario}), then change ${loc} until it passes`
|
||||
: `Write a test that shows the defect at ${loc} that ${input.engine === 'security-review' ? '/security-review' : '/code-review'} reported, then change ${loc} until it passes`),
|
||||
: `Write a test that shows the defect at ${loc} that ${spec.label} reported, then change ${loc} until it passes`),
|
||||
engine: input.engine,
|
||||
};
|
||||
if (scope && !scope.has(file)) out_of_scope.push(out);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
// - its `## Answers` row says `accepted` with a reason that is not hollow
|
||||
// (lib/util/approval-log.mjs `isHollowAnswer`);
|
||||
// - it is not a security finding (a `security` catalogue key, or a finding
|
||||
// from the `security-review` engine): those are never accepted;
|
||||
// from a security engine, `security-review` or `claude-security`): those
|
||||
// are never accepted;
|
||||
// - the latest K2 record of kind `review` with subject
|
||||
// `<review path relative to the git top level>#<id>` carries
|
||||
// `answerSha(...)` over the review's CURRENT trailing JSON block and
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
// `**Source:** engine {name}, put back by the standard-path comparison`,
|
||||
// the frontmatter list, counts, a verdict that is only raised, and, as a
|
||||
// BLOCKER, its `### Step R<n>`. An engine finding in `out_of_scope` is
|
||||
// not compared (it stays declined, `engine:out-of-scope`).
|
||||
// not compared (it stays declined, `engine:out-of-scope`), nor is one in
|
||||
// Claude Security's `below_threshold` (`engine:below-threshold`, E 3.1).
|
||||
// - outside, right after engine (E 2.5, decisions 164, 218): the trailing
|
||||
// JSON block's optional `known_outside` (Voyage's own security findings
|
||||
// the coordinator listed as outside the brief's threat model) is checked
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ import { contentOverlap, DEFAULT_THRESHOLD } from './plan-review-dedup.mjs';
|
|||
import { FULL_SHA_RE, reviewedSha } from './reviewed-commit.mjs';
|
||||
import { preExistingCheck, scopeShas } from './pre-existing.mjs';
|
||||
import { getRule } from './rule-catalogue.mjs';
|
||||
import { SECURITY_ENGINES } from './engine-findings.mjs';
|
||||
import { decideStop, describeStop, roundSummary } from './stop-rule.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
|
|
@ -329,11 +330,12 @@ function bestMatch(p, current, taken) {
|
|||
|
||||
/**
|
||||
* A security finding: its catalogue category is `security`, or it came from
|
||||
* the `security-review` engine. Never accepted, never left out as pre-existing.
|
||||
* a security engine (`security-review`, `claude-security`: engine-findings.mjs
|
||||
* SECURITY_ENGINES). Never accepted, never left out as pre-existing.
|
||||
* Defined here and re-exported by review-answers.mjs, which imports this module.
|
||||
*/
|
||||
export function isSecurityFinding(f) {
|
||||
return getRule(f?.rule_key)?.category === 'security' || f?.engine === 'security-review';
|
||||
return getRule(f?.rule_key)?.category === 'security' || SECURITY_ENGINES.includes(f?.engine);
|
||||
}
|
||||
|
||||
/** The folded finding: the current one's place and text, the higher severity, a security origin never lost. */
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@
|
|||
// key, for a security finding by its own reviewers (plan-critic,
|
||||
// scope-guardian, the code reviewer's part for command texts) that
|
||||
// SECURITY_INJECTION does not cover. SECURITY_REVIEW_VULNERABILITY is the
|
||||
// engines' key only: Voyage's reviewers never use it, and a finding under it
|
||||
// is never Voyage's own (lib/review/threat-model.mjs).
|
||||
// engines' key only (`/security-review`, and Claude Security since E 3.1):
|
||||
// Voyage's reviewers never use it, and a finding under it is never Voyage's
|
||||
// own (lib/review/threat-model.mjs).
|
||||
//
|
||||
// `memory_route` decides where a finding class that recurs across two
|
||||
// review.md files is proposed as project memory (P5, lib/review/recurrence.mjs):
|
||||
|
|
@ -144,7 +145,7 @@ export const RULE_CATALOGUE = Object.freeze([
|
|||
rule_key: 'SECURITY_REVIEW_VULNERABILITY',
|
||||
severity: 'BLOCKER',
|
||||
category: 'security',
|
||||
description: "A vulnerability reported by a security engine (Claude Code's /security-review) that no narrower rule_key covers. The engines' key only: Voyage's own reviewers use SECURITY_RISK.",
|
||||
description: "A vulnerability reported by a security engine (Claude Security, or Claude Code's /security-review) that no narrower rule_key covers. The engines' key only: Voyage's own reviewers use SECURITY_RISK.",
|
||||
memory_route: null,
|
||||
}),
|
||||
Object.freeze({
|
||||
|
|
|
|||
318
lib/review/security-engine.mjs
Normal file
318
lib/review/security-engine.mjs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue