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
|
|
@ -32,18 +32,21 @@ This file holds only your own role: the bounded passes and the verdict.
|
|||
You will receive a prompt containing:
|
||||
- **Reviewer outputs** — JSON-block payloads from
|
||||
`brief-conformance-reviewer` and `code-correctness-reviewer` (in `quick`
|
||||
mode, only the latter), plus the engine payloads from Claude Code's own
|
||||
review: `claude-code-review` (`/code-review`) and `claude-security-review`
|
||||
(`/security-review`, not in `quick`). Engine findings carry the catalogue
|
||||
keys `CODE_REVIEW_DEFECT` / `CODE_REVIEW_IMPROVEMENT` (MINOR: simplification,
|
||||
efficiency, reuse, style, readability) / `SECURITY_REVIEW_VULNERABILITY` (or a
|
||||
narrower key) and are treated exactly like any reviewer's findings.
|
||||
mode, only the latter), plus the engine payloads: `claude-code-review`
|
||||
(Claude Code's `/code-review`) and, not in `quick`, the one security engine
|
||||
`/trekreview` chose: `claude-security` (Claude Security) or
|
||||
`claude-security-review` (Claude Code's `/security-review`), or none. Engine
|
||||
findings carry the catalogue keys `CODE_REVIEW_DEFECT` /
|
||||
`CODE_REVIEW_IMPROVEMENT` (MINOR: simplification, efficiency, reuse, style,
|
||||
readability) / `SECURITY_REVIEW_VULNERABILITY` (or a narrower key) and are
|
||||
treated exactly like any reviewer's findings.
|
||||
- **Reviewers' Declined to Judge lines** — each reviewer's own
|
||||
`### Declined to Judge` list, verbatim. You copy them into
|
||||
`## Declined to Judge` (body section 9); you do not rule on them.
|
||||
- **Expected reviewers** — the names that were due, engines included, and
|
||||
each engine recorded as not run with its reason. A name that did not report
|
||||
forbids `ALLOW` (Pass 4).
|
||||
forbids `ALLOW` (Pass 4). When no security engine could run, the set holds
|
||||
`security-engine`, which never reports: security is not measured, never 0.
|
||||
- **Engine findings outside the review scope** — the `out_of_scope` list from
|
||||
`lib/review/engine-findings.mjs`. List each one under `## Declined to Judge`
|
||||
(`engine:out-of-scope`): a real claim on a file this review did not cover.
|
||||
|
|
@ -51,6 +54,12 @@ You will receive a prompt containing:
|
|||
`/code-review` (given exactly this range): that one is declined but does not
|
||||
hold `ALLOW` back. A MAJOR one still does: a change that breaks an
|
||||
unchanged caller is cited at the caller.
|
||||
- **Claude Security findings below its threshold** — the `below_threshold`
|
||||
list of the `claude-security` payload (`lib/review/security-engine.mjs`):
|
||||
findings Claude Security marked below confidence `high` (decision 219). List
|
||||
each one under `## Declined to Judge` (`engine:below-threshold`) and in the
|
||||
JSON `declined_to_judge`. It is `unverified`: not counted, never in
|
||||
`findings`, and it forbids `ALLOW`.
|
||||
- **Triage map** — `{file → deep-review|summary-only|skip, reason}` from
|
||||
the /trekreview triage gate.
|
||||
- **Brief metadata** — `task`, `slug`, `project_dir`, `brief_path` from
|
||||
|
|
@ -93,8 +102,8 @@ collide:
|
|||
not in the catalogue (BLOCKER > MAJOR > MINOR > SUGGESTION);
|
||||
2. if the catalogue tier ties (for a catalogue key it always does,
|
||||
since the triplet shares the `rule_key`), prefer a reviewer-agent
|
||||
finding over an engine finding (one from `claude-code-review` /
|
||||
`claude-security-review`);
|
||||
finding over an engine finding (one from `claude-code-review`,
|
||||
`claude-security-review` or `claude-security`);
|
||||
3. among findings of one kind, the higher asserted severity;
|
||||
4. among reviewer-agent findings, prefer the finding from
|
||||
`brief-conformance-reviewer` (its findings are anchored to the brief);
|
||||
|
|
@ -125,9 +134,10 @@ is load-bearing** — see *Suppression is two-valued* below:
|
|||
| Accuracy | `file` resolves under the repo root AND `line` ≥ 0 | Path traversal escape or negative line | **dropped** (`accuracy:refuted`). A line past the end of the file (when its line count is known) is not refuted: the citation is wrong, the claim is unchecked → `unverified` (`accuracy:line-out-of-range`) |
|
||||
| Actionability | `recommended_action` is non-empty AND begins with an imperative verb | Empty action, "consider …" hedges, or restating the title | `unverified` (`actionability:empty`) |
|
||||
|
||||
A finding that carries `engine` (`code-review` / `security-review`) came
|
||||
from Claude Code's own review through `lib/review/engine-findings.mjs`, which
|
||||
writes its action from the engine's failure scenario; it is
|
||||
A finding that carries `engine` (`code-review` / `security-review` /
|
||||
`claude-security`) came from an engine through `lib/review/engine-findings.mjs`
|
||||
(Claude Security's through `lib/review/security-engine.mjs`), which writes its
|
||||
action from the engine's fix or failure scenario; it is
|
||||
never set aside for Actionability. (Before this rule the converter wrote
|
||||
`Resolve: <title>`, and 8 of 8 engine findings were set aside in the
|
||||
voyage-lekerepo trial, 2026-10-06.)
|
||||
|
|
@ -177,16 +187,16 @@ Remove findings that fail ANY of these tests:
|
|||
catalogue tier; this is a correction, neither a drop nor an unverified.
|
||||
|
||||
**The brief-conflict rule (one fixed rule).** Read the **Brief choices** input.
|
||||
An engine finding (`engine: code-review` or `security-review`) whose recommended action
|
||||
An engine finding (`engine: code-review`, `security-review` or `claude-security`) whose recommended action
|
||||
contradicts a passed Non-Goal or stated choice gets `contradicts_brief: "<verbatim>"`
|
||||
(that Non-Goal or choice, word for word) and is kept, never removed;
|
||||
only a `/code-review` finding at the MAJOR tier and at severity MAJOR then drops to MINOR, with `brief_lowered_from: MAJOR`
|
||||
(in place of the catalogue reset above);
|
||||
a BLOCKER-tier, a MINOR-tier and any `/security-review` finding only get the mark,
|
||||
a BLOCKER-tier, a MINOR-tier and any finding from a security engine (`/security-review`, Claude Security) only get the mark,
|
||||
and no engine finding is otherwise written below its catalogue tier.
|
||||
The rule reads engine findings alone, so only an engine finding is ever marked:
|
||||
`engine` is written only on a finding whose kept Pass 1 survivor itself came from an engine payload
|
||||
(`claude-code-review` → `code-review`, `claude-security-review` → `security-review`),
|
||||
(`claude-code-review` → `code-review`, `claude-security-review` → `security-review`, `claude-security` → `claude-security`),
|
||||
and a reviewer-agent finding (no `engine`) and a finding whose `rule_key` is not in the catalogue are never marked.
|
||||
Remove any `brief_lowered_from` an incoming finding carries, and any mark on a finding
|
||||
without an `engine`: only this rule writes them.
|
||||
|
|
@ -330,7 +340,8 @@ You never write `ran`, `finalized`, `round`, `round_base`, `resolved` or
|
|||
withheld `ALLOW`.
|
||||
9. `## Declined to Judge` — REQUIRED. One line per finding you set aside
|
||||
without ruling on it (every `[unverified]` one, and every
|
||||
out-of-scope engine finding: `file:line rule_key — reason`), then every
|
||||
out-of-scope engine finding and Claude Security finding below its
|
||||
threshold: `file:line rule_key — reason`), then every
|
||||
line a reviewer wrote under its own `### Declined to Judge`, copied as
|
||||
`{reviewer}: {line}` (they are not findings: they stay out of the JSON
|
||||
`declined_to_judge` array and do not change the verdict), then one line `Trimmed: N` (MINOR/SUGGESTION text cut, findings
|
||||
|
|
@ -363,7 +374,7 @@ followed by these fields:
|
|||
- `- title: {short imperative title}`
|
||||
- `- detail: {what is wrong, with citation}`
|
||||
- `- recommended_action: {one imperative step}`
|
||||
- `- engine: {code-review | security-review, when the finding came from an engine}`
|
||||
- `- engine: {code-review | security-review | claude-security, when the finding came from an engine}`
|
||||
- `- contradicts_brief: {verbatim Non-Goal or choice, only when set}`
|
||||
- `- brief_lowered_from: {MAJOR, only when lowered for the brief}`
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import { issue, ok, fail } from '../util/result.mjs';
|
|||
import { isMain } from '../util/is-main.mjs';
|
||||
import { extractFindingsBlock } from '../review/findings-schema.mjs';
|
||||
import { isKnownEntry } from '../review/threat-model.mjs';
|
||||
import { ENGINES } from '../review/engine-findings.mjs';
|
||||
|
||||
export const REVIEW_REQUIRED_FRONTMATTER = [
|
||||
'type',
|
||||
|
|
@ -86,7 +87,8 @@ const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|||
const SHA256_RE = /^[0-9a-f]{64}$/;
|
||||
const SEVERITIES = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'];
|
||||
const REVIEW_POLICY_VALUES = ['absent', 'applied', 'not-applied'];
|
||||
const RAN_ENGINES = ['code-review', 'security-review'];
|
||||
// The engines a review's `ran` may name: engine-findings.mjs ENGINES (Claude Security since E 3.1).
|
||||
const RAN_ENGINES = Object.keys(ENGINES);
|
||||
const ANSWERS_HEADER = '| Finding | Answer | Reason | Record | Carried from |';
|
||||
const ANSWER_FINDING_RE = /^([0-9a-f]{40})(?:#\d+)?$/;
|
||||
const CARRIED_FROM_RE = /^review-round-[0-9a-f]{12}-\d+\.md#[0-9a-f]{40}$/;
|
||||
|
|
|
|||
95
tests/helpers/claude-security-report.mjs
Normal file
95
tests/helpers/claude-security-report.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// tests/helpers/claude-security-report.mjs
|
||||
// A Claude Security report folder as a changes scan writes it (Claude Security
|
||||
// 0.12.0: scripts/render_report.py, scripts/lib/finding.py, scripts/lib/plugin.py),
|
||||
// for the tests of lib/review/security-engine.mjs `convert`:
|
||||
//
|
||||
// <dir>/CLAUDE-SECURITY-<UTC YYYYMMDD-HHMMSS>/
|
||||
// .gitignore `*`
|
||||
// CLAUDE-SECURITY-RESULTS.md
|
||||
// CLAUDE-SECURITY-RESULTS.jsonl one finding per line
|
||||
// CLAUDE-SECURITY-RESULTS.sarif
|
||||
// CLAUDE-SECURITY-REVISION-<first 12 hex of the commit>[-dirty].json the stamp
|
||||
//
|
||||
// `writeReport` takes the commit range and lets a test change any part of the
|
||||
// stamp (`stamp: (s) => ...`) or write the JSONL text itself (`jsonl`).
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** One JSONL record in the field order the products carry, with `over` applied. */
|
||||
export function record(over = {}) {
|
||||
return {
|
||||
id: 'F1',
|
||||
title: 'Shell command built from the branch name',
|
||||
impact: 'A pushed branch name runs a command on the machine that checks it out',
|
||||
file: 'src/run.mjs',
|
||||
line: 12,
|
||||
description: 'checkout() interpolates the branch name into a shell string.',
|
||||
exploit_scenario: 'A branch named `x;touch pwned` runs `touch pwned` when it is checked out',
|
||||
preconditions: ['the attacker can push a branch'],
|
||||
category: 'Injection',
|
||||
severity: 'HIGH',
|
||||
confidence: 'high',
|
||||
recommendation: 'Pass the branch name as an argv element through execFile, never through a shell string',
|
||||
cwe_id: 'CWE-78',
|
||||
snippet: 'exec(`git checkout ${branch}`)',
|
||||
symbol: 'checkout',
|
||||
declared_line: 12,
|
||||
via_change: null,
|
||||
other_cwe_ids: [],
|
||||
claudeSecurityPluginFindingId: 'a3f9c2d1e0b4',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** The stamp of a verified changes scan of `base..head`. */
|
||||
export function stampOf({ head, base, dirty = false, records = [] }) {
|
||||
const count = (sev) => records.filter((r) => r.severity === sev).length;
|
||||
return {
|
||||
generated_at: '2026-11-08T12:00:00+00:00',
|
||||
duration_s: 412,
|
||||
scan_id: '6f1d2a4e-5b7c-4e1a-9d3f-2c8b0a7e6d51',
|
||||
mode: 'changes',
|
||||
scan_prefix: '',
|
||||
scope: [],
|
||||
revision: { versioned: true, commit: head, branch: 'feat/x', dirty, base, merge_base: base },
|
||||
revision_source: 'git',
|
||||
model: 'claude-opus-5-5',
|
||||
effort: 'high',
|
||||
run_shape: {
|
||||
requested_effort: 'high', collapsed: null, source: 'coverage.json',
|
||||
diff_files: 2, diff_lines: 30, pre_existing: 0, scope_files: 2,
|
||||
empty_diff: false, empty_scope: false, researchers_dispatched: 3, verification_runs: 1,
|
||||
},
|
||||
findings: { total: records.length, critical: count('CRITICAL'), high: count('HIGH'), medium: count('MEDIUM'), low: count('LOW') },
|
||||
verification: {
|
||||
status: 'verified', candidates: records.length, candidates_deduped: records.length,
|
||||
panel_votes: 3 * records.length, panel_reviewed_findings: records.length,
|
||||
panel_quorum_findings: records.length, unreviewed_candidate_sites: 0,
|
||||
incomplete_panel_candidates: 0, attested_findings: 0, reason: null, reason_kind: null,
|
||||
researchers_dispatched: 3, researchers_returned: 3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one report folder under `dir` and return its path.
|
||||
* @param {string} dir - the scanned repo's root
|
||||
* @param {{ head: string, base: string, ts?: string, dirty?: boolean, records?: object[],
|
||||
* jsonl?: string|null, stamp?: (s: object) => object, stampName?: string }} opts
|
||||
* `jsonl: null` writes no JSONL file; `stamp` returns the stamp to write.
|
||||
*/
|
||||
export function writeReport(dir, { head, base, ts = '20261108-120000', dirty = false, records = [record()], jsonl, stamp = (s) => s, stampName }) {
|
||||
const folder = join(dir, `CLAUDE-SECURITY-${ts}`);
|
||||
mkdirSync(folder, { recursive: true });
|
||||
writeFileSync(join(folder, '.gitignore'), '*\n');
|
||||
writeFileSync(join(folder, 'CLAUDE-SECURITY-RESULTS.md'), `# Claude Security results\n\n${records.length} finding(s).\n`);
|
||||
writeFileSync(join(folder, 'CLAUDE-SECURITY-RESULTS.sarif'), JSON.stringify({ version: '2.1.0', runs: [] }));
|
||||
if (jsonl !== null) {
|
||||
writeFileSync(join(folder, 'CLAUDE-SECURITY-RESULTS.jsonl'),
|
||||
jsonl ?? records.map((r) => `${JSON.stringify(r)}\n`).join(''));
|
||||
}
|
||||
const name = stampName ?? `CLAUDE-SECURITY-REVISION-${head.slice(0, 12)}${dirty ? '-dirty' : ''}.json`;
|
||||
writeFileSync(join(folder, name), JSON.stringify(stamp(stampOf({ head, base, dirty, records })), null, 2));
|
||||
return folder;
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ import {
|
|||
REFUTING_REASONS,
|
||||
UNVERIFIED_REASONS,
|
||||
runContract,
|
||||
lowerableBriefConflict,
|
||||
illegalBriefLowering,
|
||||
} from '../../lib/review/coordinator-contract.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
|
@ -616,3 +618,46 @@ test('runContract — a MAJOR /code-review defect cited at an unchanged file sti
|
|||
assert.notEqual(c.verdict, 'ALLOW');
|
||||
assert.equal(c.declined_to_judge.length, 1);
|
||||
});
|
||||
|
||||
// ---- E 3.1: Claude Security is a security engine; below its threshold is declined ----
|
||||
|
||||
const csFinding = (over) => ({
|
||||
id: 'c'.repeat(40), severity: 'BLOCKER', rule_key: 'SECURITY_REVIEW_VULNERABILITY',
|
||||
file: 'src/run.mjs', line: 12, brief_ref: 'NFR - security', title: 'Shell command built from the branch name',
|
||||
detail: 'A crafted branch name runs a command', recommended_action: 'Pass the name as an argv element',
|
||||
...over,
|
||||
});
|
||||
|
||||
test('[E3] engine:below-threshold is an unverified reason: declined to judge, not counted, it keeps the verdict from ALLOW', () => {
|
||||
assert.ok(UNVERIFIED_REASONS.includes('engine:below-threshold'));
|
||||
assert.equal(classifySuppression('engine:below-threshold'), 'unverified');
|
||||
const below = csFinding({ engine: 'claude-security', title: 'Token compared with ===' });
|
||||
const c = runContract([{ reviewer: 'claude-security', findings: [] }],
|
||||
{ expectedReviewers: ['claude-security'], engineBelowThreshold: [below] });
|
||||
assert.equal(c.verdict, 'WARN', JSON.stringify(c.allow_blocked_by));
|
||||
assert.equal(c.counts.BLOCKER, 0, 'a below-threshold finding is not counted');
|
||||
assert.deepEqual(c.findings, []);
|
||||
assert.deepEqual(c.declined_to_judge.map((d) => [d.title, d.reason]), [['Token compared with ===', 'engine:below-threshold']]);
|
||||
assert.deepEqual(c.allow_blocked_by, ['unverified:engine:below-threshold (1)']);
|
||||
});
|
||||
|
||||
test('[E3] a Claude Security payload\'s findings carry engine claude-security, read from the payload', () => {
|
||||
const { findings } = ingest([{ reviewer: 'claude-security', findings: [csFinding({ engine: 'code-review' })] }]);
|
||||
assert.equal(findings[0].engine, 'claude-security', 'the engine comes from the payload, never from the finding');
|
||||
});
|
||||
|
||||
test('[E3] a Claude Security finding outside the scope forbids ALLOW, as a /security-review one does', () => {
|
||||
const c = runContract([], { engineOutOfScope: [csFinding({ engine: 'claude-security', file: 'lib/old.mjs' })] });
|
||||
assert.notEqual(c.verdict, 'ALLOW');
|
||||
assert.deepEqual(c.declined_to_judge.map((d) => d.reason), ['engine:out-of-scope']);
|
||||
});
|
||||
|
||||
test('[E3] the brief-conflict rule: a Claude Security finding is only ever marked, never lowered', () => {
|
||||
const marked = csFinding({ engine: 'claude-security', contradicts_brief: 'No shell wrapper (Non-Goal)', rule_key: 'CODE_REVIEW_DEFECT', severity: 'MAJOR' });
|
||||
assert.equal(lowerableBriefConflict(marked), false, 'only a /code-review MAJOR drops');
|
||||
assert.equal(illegalBriefLowering({ ...marked, severity: 'MINOR', brief_lowered_from: 'MAJOR' }), true);
|
||||
assert.equal(illegalBriefLowering(csFinding({ engine: 'claude-security', severity: 'MAJOR' })), true,
|
||||
'a Claude Security finding below its catalogue tier is illegal');
|
||||
assert.equal(illegalBriefLowering(csFinding({ engine: 'claude-security', contradicts_brief: 'No shell wrapper (Non-Goal)' })), false,
|
||||
'marked at its tier is the legal shape');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { dirname, join } from 'node:path';
|
|||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { computeFindingId, computeFindingIds } from '../../lib/parsers/finding-id.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
import { record, writeReport } from '../helpers/claude-security-report.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const COMPARE = join(ROOT, 'lib', 'review', 'engine-compare.mjs');
|
||||
|
|
@ -31,6 +32,7 @@ const FINALIZE = join(ROOT, 'lib', 'review', 'review-finalize.mjs');
|
|||
const FILL = join(ROOT, 'lib', 'review', 'fill-finding-ids.mjs');
|
||||
const VALIDATOR = join(ROOT, 'lib', 'validators', 'review-validator.mjs');
|
||||
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
|
||||
const SECURITY_CLI = join(ROOT, 'lib', 'review', 'security-engine.mjs');
|
||||
const REVIEW_REL = '.claude/projects/2026-11-02-eng/review.md';
|
||||
const SOURCE_LINE = (engine) => `**Source:** engine ${engine}, put back by the standard-path comparison`;
|
||||
|
||||
|
|
@ -355,3 +357,70 @@ test('[ENG] a review finalized with a put-back has every id computed, no placeho
|
|||
const bgate = bx.gate();
|
||||
assert.equal(bgate.status, 1, `the gate reads BLOCK: ${bgate.stdout}${bgate.stderr}`);
|
||||
});
|
||||
|
||||
// --- E 3.1: Claude Security's payload, converted from its report ------------------
|
||||
|
||||
/** Claude Security's report of `start..end` in the repo root, converted by the real CLI and saved as Phase 4.7 saves it. */
|
||||
function claudeSecurity(fx, records) {
|
||||
writeReport(fx.repo, { head: fx.end, base: fx.start, records });
|
||||
const scope = join(fx.project, '.review-scope.txt');
|
||||
writeFileSync(scope, 'src/a.mjs\nsrc/b.mjs\ndocs/guide/x.md\n');
|
||||
const r = run([SECURITY_CLI, 'convert', '--report-dir', fx.repo, '--base', fx.start, '--head', fx.end, '--scope', scope]);
|
||||
assert.equal(r.status, 0, `security-engine convert: ${r.stdout}${r.stderr}`);
|
||||
writeFileSync(join(fx.project, '.review-engine-payload-claude-security.json'), r.stdout);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
|
||||
const CS_HIGH = record({ file: 'src/a.mjs', line: 1, title: 'A shell command is built from the branch name' });
|
||||
const CS_MEDIUM = record({ id: 'F2', file: 'src/b.mjs', line: 3, title: 'A path is joined from a query value', confidence: 'medium', cwe_id: 'CWE-22' });
|
||||
|
||||
test('[E3] a Claude Security finding the coordinator dropped is put back; a below-threshold one never is; ran names claude-security', () => {
|
||||
const fx = engRepo();
|
||||
fx.write(engReview({
|
||||
start: fx.start, end: fx.end, findings: [REVIEWER_MINOR],
|
||||
declined: [{ file: 'src/b.mjs', line: 3, rule_key: 'SECURITY_REVIEW_VULNERABILITY', severity: 'BLOCKER', title: CS_MEDIUM.title, reason: 'engine:below-threshold' }],
|
||||
}));
|
||||
const payload = claudeSecurity(fx, [CS_HIGH, CS_MEDIUM]);
|
||||
assert.ok(Array.isArray(payload.below_threshold), `the payload has below_threshold: ${JSON.stringify(payload)}`);
|
||||
assert.deepEqual(payload.below_threshold.map((b) => b.title), [CS_MEDIUM.title]);
|
||||
const r = fx.finalize();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
const text = fx.read();
|
||||
const json = jsonOf(text);
|
||||
const back = on(json.findings, 'src/a.mjs', 1, 'SECURITY_REVIEW_VULNERABILITY');
|
||||
assert.equal(back.length, 1, `the dropped Claude Security finding is back: ${JSON.stringify(json.findings.map((f) => f.title))}`);
|
||||
assert.equal(back[0].engine, 'claude-security');
|
||||
assert.equal(back[0].severity, 'BLOCKER');
|
||||
assert.equal(json.verdict, 'BLOCK');
|
||||
assert.ok(entryOf(sectionOf(text, '## Findings (BLOCKER)'), back[0].id).includes(SOURCE_LINE('claude-security')),
|
||||
`the entry names its source:\n${sectionOf(text, '## Findings (BLOCKER)')}`);
|
||||
assert.equal(json.findings.filter((f) => f.title === CS_MEDIUM.title).length, 0, 'a below-threshold finding is never put back');
|
||||
assert.deepEqual(json.ran.engines, ['claude-security'], 'ran names the engine whose payload is in the project folder');
|
||||
const v = fx.validate();
|
||||
assert.equal(v.status, 0, `review-validator --strict reads ran: ${v.stdout}${v.stderr}`);
|
||||
const gate = fx.gate();
|
||||
assert.equal(gate.status, 1, `the gate reads BLOCK: ${gate.stdout}${gate.stderr}`);
|
||||
});
|
||||
|
||||
test('[E3] the verdict gate reads a Claude Security finding missing from the review as not measured; a below-threshold one alone is not', () => {
|
||||
// Only a below-threshold finding: the review that declines it is read (exit 0, WARN).
|
||||
const declined = [{ file: 'src/b.mjs', line: 3, rule_key: 'SECURITY_REVIEW_VULNERABILITY', severity: 'BLOCKER', title: CS_MEDIUM.title, reason: 'engine:below-threshold' }];
|
||||
// The coordinator's review: the reviewer MINOR, the below-threshold finding declined, so WARN.
|
||||
const warn = (f) => engReview({ start: f.start, end: f.end, findings: [REVIEWER_MINOR], declined })
|
||||
.replace('verdict: ALLOW', 'verdict: WARN')
|
||||
.replace('"verdict": "ALLOW"', '"verdict": "WARN"')
|
||||
.replace('"allow_blocked_by": []', '"allow_blocked_by": ["unverified:engine:below-threshold (1)"]');
|
||||
const fx = engRepo();
|
||||
fx.write(warn(fx));
|
||||
assert.equal(jsonOf(fx.read()).verdict, 'WARN', 'the fixture review is WARN');
|
||||
claudeSecurity(fx, [CS_MEDIUM]);
|
||||
const quiet = fx.gate();
|
||||
assert.equal(quiet.status, 0, `a below-threshold finding is no dropped engine finding: ${quiet.stdout}${quiet.stderr}`);
|
||||
// A high-confidence finding the review does not hold: not measured.
|
||||
const hx = engRepo();
|
||||
hx.write(warn(hx));
|
||||
claudeSecurity(hx, [CS_HIGH, CS_MEDIUM]);
|
||||
const loud = hx.gate();
|
||||
assert.equal(loud.status, 2, loud.stdout + loud.stderr);
|
||||
assert.match(String(parse(loud).reason), /engine finding not in review: BLOCKER SECURITY_REVIEW_VULNERABILITY at src\/a\.mjs:1/, loud.stdout);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1078,3 +1078,11 @@ test('[STP] a round with no readable JSON block at P = 2: prepare is not measure
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- E 3.1: a finding from Claude Security is a security finding ------------------
|
||||
|
||||
test('[E3] isSecurityFinding: a finding from Claude Security is a security finding whatever its key, as one from /security-review is', () => {
|
||||
assert.equal(rounds.isSecurityFinding({ engine: 'claude-security', rule_key: 'CODE_REVIEW_DEFECT' }), true);
|
||||
assert.equal(rounds.isSecurityFinding({ engine: 'security-review', rule_key: 'CODE_REVIEW_DEFECT' }), true);
|
||||
assert.equal(rounds.isSecurityFinding({ engine: 'code-review', rule_key: 'CODE_REVIEW_DEFECT' }), false, 'control: /code-review is not a security engine');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -114,3 +114,16 @@ test('[V12] a malformed round, pre-existing or ran field at 1.2 fails with its c
|
|||
assert.ok(codes(r.errors).includes(code), `${name}: ${code} expected, got ${JSON.stringify(r.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- E 3.1: Claude Security is an engine that ran ---------------------------------
|
||||
|
||||
test('[E3] ran.engines may name claude-security; an unknown engine is still refused', () => {
|
||||
const v12 = fixture('review-1-2.md');
|
||||
const from = '"engines": ["code-review", "security-review"]';
|
||||
assert.ok(v12.includes(from), 'the 1.2 fixture names its engines');
|
||||
const r = validateReviewContent(mutate(v12, from, '"engines": ["claude-security", "code-review"]'), { strict: true });
|
||||
assert.ok(!codes(r.errors).includes('REVIEW_ROUND_MALFORMED'), JSON.stringify(r.errors));
|
||||
assert.equal(r.valid, true, JSON.stringify(r.errors));
|
||||
const bad = validateReviewContent(mutate(v12, from, '"engines": ["claude-security-review"]'), { strict: true });
|
||||
assert.ok(codes(bad.errors).includes('REVIEW_ROUND_MALFORMED'), 'control: a reviewer name is not an engine');
|
||||
});
|
||||
|
|
|
|||
394
tests/lib/security-engine.test.mjs
Normal file
394
tests/lib/security-engine.test.mjs
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
// tests/lib/security-engine.test.mjs
|
||||
// E 3.1 (decisions 175, 196, 219): the security engine of /trekreview.
|
||||
// choose - which engine reviews security: 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
|
||||
// none (no-engine, no-origin, empty-diff) = "not measured", never 0.
|
||||
// A git failure or bad usage is exit 2, never a reason.
|
||||
// convert - Claude Security's report folder turned into a reviewer payload:
|
||||
// confidence `high` -> engine findings, the rest -> `below_threshold`,
|
||||
// never in `findings`; a scan that cannot be trusted is exit 2.
|
||||
// ENGINES - claude-security is an engine of its own, and the action written
|
||||
// for a finding names the engine that reported it.
|
||||
// none - no engine chosen: the expected reviewer `security-engine` never
|
||||
// reports, so the verdict gate exits 2, never ALLOW.
|
||||
// choose runs the real CLI in throwaway git repos with no hooks and no global
|
||||
// or system git config.
|
||||
|
||||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
import { record, writeReport } from '../helpers/claude-security-report.mjs';
|
||||
import { convertReport, NO_ENGINE_REVIEWER } from '../../lib/review/security-engine.mjs';
|
||||
import { ENGINES, toReviewerPayload } from '../../lib/review/engine-findings.mjs';
|
||||
import { runContract } from '../../lib/review/coordinator-contract.mjs';
|
||||
import { decideVerdict } from '../../lib/review/verdict-gate.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const CLI = join(ROOT, 'lib', 'review', 'security-engine.mjs');
|
||||
const GIT_ENV = { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' };
|
||||
const HEX40 = /^[0-9a-f]{40}$/;
|
||||
|
||||
const made = [];
|
||||
after(() => { for (const b of made) rmSync(b, { recursive: true, force: true }); });
|
||||
|
||||
const run = (args) => spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', env: childEnv(GIT_ENV) });
|
||||
const parse = (r) => { try { return JSON.parse(r.stdout); } catch { return {}; } };
|
||||
|
||||
function g(cwd, ...args) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv(GIT_ENV) });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
/** A throwaway repo with two commits (start, end) that change a.mjs; no origin remote. */
|
||||
function repo() {
|
||||
const base = mkdtempSync(join(tmpdir(), 'security-engine-'));
|
||||
made.push(base);
|
||||
const dir = join(base, 'repo');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
mkdirSync(join(base, 'hooks-none'));
|
||||
g(dir, 'init', '-q', '-b', 'main');
|
||||
appendFileSync(join(dir, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
|
||||
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
|
||||
writeFileSync(join(dir, 'a.mjs'), 'export const a = 1;\n');
|
||||
g(dir, 'add', 'a.mjs');
|
||||
g(dir, 'commit', '-q', '-m', 'chore: start');
|
||||
const start = g(dir, 'rev-parse', 'HEAD');
|
||||
writeFileSync(join(dir, 'a.mjs'), 'export const a = 2;\n');
|
||||
g(dir, 'add', 'a.mjs');
|
||||
g(dir, 'commit', '-q', '-m', 'feat: the work');
|
||||
const end = g(dir, 'rev-parse', 'HEAD');
|
||||
return {
|
||||
base, dir, start, end,
|
||||
/** An `origin` remote (never fetched) whose HEAD points at `sha`. */
|
||||
originAt(sha) {
|
||||
g(dir, 'remote', 'add', 'origin', join(base, 'nowhere.git'));
|
||||
g(dir, 'update-ref', 'refs/remotes/origin/main', sha);
|
||||
g(dir, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
||||
},
|
||||
/** An `origin` remote with no remote-tracking refs at all. */
|
||||
originWithoutHead() {
|
||||
g(dir, 'remote', 'add', 'origin', join(base, 'nowhere.git'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const choose = (fx, { base = fx.start, head = fx.end, cs = 'missing' } = {}) =>
|
||||
run(['choose', '--repo', fx.dir, '--base', base, '--head', head, '--claude-security', cs]);
|
||||
|
||||
function decided(r) {
|
||||
assert.equal(r.status, 0, `choose decides: ${r.stdout}${r.stderr}`);
|
||||
const j = parse(r);
|
||||
assert.deepEqual(Object.keys(j).sort(), ['engine', 'reason'], `the output is {engine, reason}: ${r.stdout}`);
|
||||
return j;
|
||||
}
|
||||
|
||||
// ---- choose ----------------------------------------------------------------------
|
||||
|
||||
test('[CHS] Claude Security available and base..head has committed changes: claude-security', () => {
|
||||
const fx = repo();
|
||||
assert.deepEqual(decided(choose(fx, { cs: 'available' })), { engine: 'claude-security', reason: 'claude-security-available' });
|
||||
});
|
||||
|
||||
test('[CHS] Claude Security missing, origin/HEAD behind HEAD: security-review', () => {
|
||||
const fx = repo();
|
||||
fx.originAt(fx.start);
|
||||
assert.deepEqual(decided(choose(fx)), { engine: 'security-review', reason: 'claude-security-missing' });
|
||||
});
|
||||
|
||||
test('[CHS] Claude Security available but base..head empty, origin/HEAD behind HEAD: security-review', () => {
|
||||
const fx = repo();
|
||||
fx.originAt(fx.start);
|
||||
assert.deepEqual(decided(choose(fx, { base: fx.end, cs: 'available' })), { engine: 'security-review', reason: 'claude-security-empty-diff' });
|
||||
});
|
||||
|
||||
test('[CHS] origin/HEAD equals HEAD and Claude Security is missing: no engine, empty-diff (a pushed main is not measured, never 0)', () => {
|
||||
const fx = repo();
|
||||
fx.originAt(fx.end);
|
||||
assert.deepEqual(decided(choose(fx)), { engine: null, reason: 'empty-diff' });
|
||||
});
|
||||
|
||||
test('[CHS] Claude Security available, base..head empty, no origin/HEAD: no engine, empty-diff', () => {
|
||||
const fx = repo();
|
||||
assert.deepEqual(decided(choose(fx, { base: fx.end, cs: 'available' })), { engine: null, reason: 'empty-diff' });
|
||||
});
|
||||
|
||||
test('[CHS] only committed changes count: an uncommitted edit on top of an empty range is still empty-diff', () => {
|
||||
const fx = repo();
|
||||
writeFileSync(join(fx.dir, 'a.mjs'), 'export const a = 3;\n');
|
||||
assert.deepEqual(decided(choose(fx, { base: fx.end, cs: 'available' })), { engine: null, reason: 'empty-diff' });
|
||||
});
|
||||
|
||||
test('[CHS] no origin remote and Claude Security missing: no engine, no-engine', () => {
|
||||
const fx = repo();
|
||||
assert.deepEqual(decided(choose(fx)), { engine: null, reason: 'no-engine' });
|
||||
});
|
||||
|
||||
test('[CHS] an origin remote whose HEAD does not resolve, Claude Security missing: no engine, no-origin', () => {
|
||||
const fx = repo();
|
||||
fx.originWithoutHead();
|
||||
assert.deepEqual(decided(choose(fx)), { engine: null, reason: 'no-origin' });
|
||||
});
|
||||
|
||||
test('[CHS] a git failure is exit 2 (could not run), never a reason', () => {
|
||||
const fx = repo();
|
||||
const plain = join(fx.base, 'plain');
|
||||
mkdirSync(plain);
|
||||
const notRepo = run(['choose', '--repo', plain, '--base', fx.start, '--head', fx.end, '--claude-security', 'available']);
|
||||
assert.equal(notRepo.status, 2, `not a git repository: ${notRepo.stdout}${notRepo.stderr}`);
|
||||
assert.equal(notRepo.stdout, '', 'nothing on stdout');
|
||||
const unknown = 'f'.repeat(40);
|
||||
for (const [base, head] of [[unknown, fx.end], [fx.start, unknown]]) {
|
||||
const r = choose(fx, { base, head, cs: 'available' });
|
||||
assert.equal(r.status, 2, `a sha that does not resolve: ${r.stdout}${r.stderr}`);
|
||||
assert.equal(r.stdout, '');
|
||||
}
|
||||
});
|
||||
|
||||
test('[CHS] bad usage is exit 2', () => {
|
||||
const fx = repo();
|
||||
const cases = [
|
||||
['no subcommand', []],
|
||||
['an unknown subcommand', ['pick', '--repo', fx.dir]],
|
||||
['--claude-security missing', ['choose', '--repo', fx.dir, '--base', fx.start, '--head', fx.end]],
|
||||
['--claude-security maybe', ['choose', '--repo', fx.dir, '--base', fx.start, '--head', fx.end, '--claude-security', 'maybe']],
|
||||
['--base a ref name', ['choose', '--repo', fx.dir, '--base', 'HEAD~1', '--head', fx.end, '--claude-security', 'available']],
|
||||
['--base an option', ['choose', '--repo', fx.dir, '--base', '--output=/tmp/x', '--head', fx.end, '--claude-security', 'available']],
|
||||
['--repo missing', ['choose', '--base', fx.start, '--head', fx.end, '--claude-security', 'available']],
|
||||
['an unknown flag', ['choose', '--repo', fx.dir, '--base', fx.start, '--head', fx.end, '--claude-security', 'available', '--force']],
|
||||
];
|
||||
for (const [name, args] of cases) {
|
||||
const r = run(args);
|
||||
assert.equal(r.status, 2, `${name}: exit 2, got ${r.status}: ${r.stdout}${r.stderr}`);
|
||||
assert.equal(r.stdout, '', `${name}: nothing on stdout`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- convert ---------------------------------------------------------------------
|
||||
|
||||
const BASE = 'a'.repeat(40);
|
||||
const HEAD = 'b'.repeat(40);
|
||||
|
||||
function reportDir() {
|
||||
const d = mkdtempSync(join(tmpdir(), 'security-engine-report-'));
|
||||
made.push(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
const MEDIUM = record({ id: 'F3', title: 'Path joined from a query value', file: 'src/run.mjs', line: 30, confidence: 'medium', cwe_id: 'CWE-22', category: 'Path Traversal' });
|
||||
const LOW = record({ id: 'F4', title: 'Token compared with ===', file: 'src/run.mjs', line: 44, confidence: 'low', severity: 'LOW', cwe_id: 'CWE-208' });
|
||||
const OUTSIDE = record({ id: 'F2', title: 'Old helper leaks a handle path', file: 'lib/old.mjs', line: 4 });
|
||||
|
||||
test('[CNV] a verified changes scan of base..head: confidence high becomes an engine finding, the rest go to below_threshold and never into findings', () => {
|
||||
const dir = reportDir();
|
||||
writeReport(dir, { head: HEAD, base: BASE, records: [record(), OUTSIDE, MEDIUM, LOW] });
|
||||
const r = convertReport({ reportDir: dir, base: BASE, head: HEAD, scopeFiles: ['src/run.mjs', 'src/other.mjs'] });
|
||||
assert.equal(r.exit, 0, r.reason);
|
||||
const p = r.payload;
|
||||
assert.equal(p.reviewer, 'claude-security');
|
||||
assert.equal(p.findings.length, 1, JSON.stringify(p.findings.map((f) => f.title)));
|
||||
const [f] = p.findings;
|
||||
assert.deepEqual([f.rule_key, f.severity, f.engine, f.file, f.line], ['SECURITY_REVIEW_VULNERABILITY', 'BLOCKER', 'claude-security', 'src/run.mjs', 12]);
|
||||
assert.equal(f.title, record().title, 'summary <- title');
|
||||
assert.ok(f.detail.includes(record().exploit_scenario), `failure_scenario <- exploit_scenario: ${f.detail}`);
|
||||
assert.ok(f.detail.includes('CWE-78'), `category <- cwe_id: ${f.detail}`);
|
||||
assert.equal(f.recommended_action, record().recommendation, 'fix <- recommendation');
|
||||
assert.equal(f.brief_ref, ENGINES['claude-security'].brief_ref);
|
||||
assert.match(f.id, HEX40);
|
||||
assert.ok(!('rule_key' in record()), 'the record carries no narrower key');
|
||||
assert.deepEqual(p.out_of_scope.map((o) => [o.file, o.engine]), [['lib/old.mjs', 'claude-security']], 'a high finding outside the scope is listed, never dropped');
|
||||
assert.deepEqual(p.below_threshold.map((b) => b.title).sort(), [LOW.title, MEDIUM.title].sort(), 'medium and low go to below_threshold');
|
||||
for (const b of p.below_threshold) assert.equal(b.engine, 'claude-security');
|
||||
const inFindings = new Set([...p.findings, ...p.out_of_scope].map((x) => x.title));
|
||||
for (const b of p.below_threshold) assert.ok(!inFindings.has(b.title), `${b.title} is never in findings or out_of_scope`);
|
||||
});
|
||||
|
||||
test('[CNV] the CLI prints the payload and exits 0', () => {
|
||||
const dir = reportDir();
|
||||
writeReport(dir, { head: HEAD, base: BASE, records: [record(), MEDIUM] });
|
||||
const scope = join(dir, 'scope.txt');
|
||||
writeFileSync(scope, 'src/run.mjs\n');
|
||||
const r = run(['convert', '--report-dir', dir, '--base', BASE, '--head', HEAD, '--scope', scope]);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
const p = parse(r);
|
||||
assert.equal(p.reviewer, 'claude-security');
|
||||
assert.equal(p.findings.length, 1);
|
||||
assert.equal(p.below_threshold.length, 1);
|
||||
});
|
||||
|
||||
test('[CNV] a -dirty stamp counts', () => {
|
||||
const dir = reportDir();
|
||||
writeReport(dir, { head: HEAD, base: BASE, dirty: true });
|
||||
const r = convertReport({ reportDir: dir, base: BASE, head: HEAD });
|
||||
assert.equal(r.exit, 0, r.reason);
|
||||
assert.equal(r.payload.findings.length, 1);
|
||||
});
|
||||
|
||||
test('[CNV] the newest folder whose stamp matches is read; a newer folder of another commit is passed over', () => {
|
||||
const dir = reportDir();
|
||||
writeReport(dir, { head: HEAD, base: BASE, ts: '20261108-100000', records: [record({ title: 'older scan' })] });
|
||||
writeReport(dir, { head: HEAD, base: BASE, ts: '20261108-110000', records: [record({ title: 'newest matching scan' })] });
|
||||
writeReport(dir, { head: 'c'.repeat(40), base: BASE, ts: '20261108-120000', records: [record({ title: 'another commit' })] });
|
||||
const r = convertReport({ reportDir: dir, base: BASE, head: HEAD });
|
||||
assert.equal(r.exit, 0, r.reason);
|
||||
assert.deepEqual(r.payload.findings.map((f) => f.title), ['newest matching scan']);
|
||||
});
|
||||
|
||||
test('[CNV] a finding file relative to the scan root is made repo-relative with the stamp\'s scan_prefix', () => {
|
||||
const dir = reportDir();
|
||||
writeReport(dir, { head: HEAD, base: BASE, records: [record({ file: 'run.mjs' })], stamp: (s) => ({ ...s, scan_prefix: 'src/' }) });
|
||||
const r = convertReport({ reportDir: dir, base: BASE, head: HEAD, scopeFiles: ['src/run.mjs'] });
|
||||
assert.equal(r.exit, 0, r.reason);
|
||||
assert.deepEqual(r.payload.findings.map((f) => f.file), ['src/run.mjs']);
|
||||
});
|
||||
|
||||
test('[CNV] a scan that found nothing is a report: exit 0, no findings', () => {
|
||||
const dir = reportDir();
|
||||
writeReport(dir, { head: HEAD, base: BASE, records: [] });
|
||||
const r = convertReport({ reportDir: dir, base: BASE, head: HEAD });
|
||||
assert.equal(r.exit, 0, r.reason);
|
||||
assert.deepEqual([r.payload.findings, r.payload.out_of_scope, r.payload.below_threshold], [[], [], []]);
|
||||
});
|
||||
|
||||
test('[CNV] not measured: exit 2, the reason on stderr, nothing on stdout', () => {
|
||||
const setV = (key, value) => (s) => ({ ...s, verification: { ...s.verification, [key]: value } });
|
||||
const dropV = (key) => (s) => { const v = { ...s.verification }; delete v[key]; return { ...s, verification: v }; };
|
||||
const setShape = (key, value) => (s) => ({ ...s, run_shape: { ...s.run_shape, [key]: value } });
|
||||
const cases = [
|
||||
['no report folder', null],
|
||||
['a stamp of another commit', { head: 'c'.repeat(40) }],
|
||||
['a stamp of another merge base', { stamp: (s) => ({ ...s, revision: { ...s.revision, merge_base: 'd'.repeat(40) } }) }],
|
||||
['a commit scan, not a changes scan', { stamp: (s) => ({ ...s, mode: 'commit' }) }],
|
||||
['verification unverified', { stamp: setV('status', 'unverified') }],
|
||||
['fewer researchers returned than dispatched', { stamp: setV('researchers_returned', 2) }],
|
||||
['researchers_returned missing', { stamp: dropV('researchers_returned') }],
|
||||
['researchers_dispatched missing', { stamp: dropV('researchers_dispatched') }],
|
||||
['the scan saw an empty diff', { stamp: setShape('empty_diff', true) }],
|
||||
['the scan saw an empty scope', { stamp: setShape('empty_scope', true) }],
|
||||
['no JSONL', { jsonl: null }],
|
||||
['a JSONL line that does not parse', { jsonl: `${JSON.stringify(record())}\n{"title": \n` }],
|
||||
['a record with an unknown confidence', { records: [record({ confidence: 'certain' })] }],
|
||||
['a record with no title', { records: [record({ title: '' })] }],
|
||||
];
|
||||
for (const [name, opts] of cases) {
|
||||
const dir = reportDir();
|
||||
if (opts !== null) writeReport(dir, { head: HEAD, base: BASE, ...opts });
|
||||
const r = run(['convert', '--report-dir', dir, '--base', BASE, '--head', HEAD]);
|
||||
assert.equal(r.status, 2, `${name}: exit 2, got ${r.status}: ${r.stdout}${r.stderr}`);
|
||||
assert.equal(r.stdout, '', `${name}: nothing on stdout`);
|
||||
assert.match(r.stderr, /not measured/, `${name}: the reason says not measured: ${r.stderr}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[CNV] an empty scope list is exit 1 (the review scope is unknown), as engine-findings.mjs', () => {
|
||||
const dir = reportDir();
|
||||
writeReport(dir, { head: HEAD, base: BASE });
|
||||
const scope = join(dir, 'scope.txt');
|
||||
writeFileSync(scope, '\n');
|
||||
const r = run(['convert', '--report-dir', dir, '--base', BASE, '--head', HEAD, '--scope', scope]);
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.equal(r.stdout, '');
|
||||
});
|
||||
|
||||
test('[CNV] bad usage is exit 2', () => {
|
||||
const dir = reportDir();
|
||||
writeReport(dir, { head: HEAD, base: BASE });
|
||||
for (const [name, args] of [
|
||||
['--head missing', ['convert', '--report-dir', dir, '--base', BASE]],
|
||||
['--base not a full sha', ['convert', '--report-dir', dir, '--base', 'main', '--head', HEAD]],
|
||||
['--report-dir missing', ['convert', '--base', BASE, '--head', HEAD]],
|
||||
['a scope file that does not exist', ['convert', '--report-dir', dir, '--base', BASE, '--head', HEAD, '--scope', join(dir, 'none.txt')]],
|
||||
]) {
|
||||
const r = run(args);
|
||||
assert.equal(r.status, 2, `${name}: exit 2, got ${r.status}: ${r.stdout}${r.stderr}`);
|
||||
assert.equal(r.stdout, '', `${name}: nothing on stdout`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- ENGINES --------------------------------------------------------------------
|
||||
|
||||
test('[ENG3] ENGINES has claude-security: reviewer claude-security, the engines\' key, its own brief_ref', () => {
|
||||
const s = ENGINES['claude-security'];
|
||||
assert.ok(s, 'ENGINES has claude-security');
|
||||
assert.equal(s.reviewer, 'claude-security');
|
||||
assert.equal(s.rule_key, 'SECURITY_REVIEW_VULNERABILITY');
|
||||
assert.equal(typeof s.brief_ref, 'string');
|
||||
assert.notEqual(s.brief_ref, ENGINES['security-review'].brief_ref, 'its own brief_ref');
|
||||
assert.match(s.brief_ref, /Claude Security/);
|
||||
});
|
||||
|
||||
test('[ENG3] the action written for a finding without a fix names the engine that reported it', () => {
|
||||
const bare = [{ file: 'src/run.mjs', line: 3, summary: 's' }];
|
||||
const action = (engine) => {
|
||||
const r = toReviewerPayload({ engine, findings: bare });
|
||||
assert.equal(r.ok, true, `${engine}: ${r.errors.join('; ')}`);
|
||||
return r.payload.findings[0].recommended_action;
|
||||
};
|
||||
assert.match(action('code-review'), /\/code-review reported/);
|
||||
assert.match(action('security-review'), /\/security-review reported/);
|
||||
assert.match(action('claude-security'), /Claude Security reported/);
|
||||
assert.doesNotMatch(action('claude-security'), /\/code-review/, 'another engine is never called /code-review');
|
||||
});
|
||||
|
||||
// ---- no engine: not measured, never ALLOW -----------------------------------------
|
||||
|
||||
// Copied from tests/lib/verdict-gate.test.mjs:29-74 (review), never imported. Changed:
|
||||
// allow_blocked_by is the one argument.
|
||||
function review(allowBlockedBy) {
|
||||
const json = JSON.stringify({ verdict: 'WARN', counts: { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 }, allow_blocked_by: allowBlockedBy, findings: [] }, null, 2);
|
||||
return `---
|
||||
type: trekreview
|
||||
review_version: "1.1"
|
||||
task: "t"
|
||||
slug: s
|
||||
project_dir: .claude/projects/2026-11-08-s/
|
||||
brief_path: .claude/projects/2026-11-08-s/brief.md
|
||||
scope_sha_end: abc
|
||||
reviewed_files_count: 1
|
||||
verdict: WARN
|
||||
findings: []
|
||||
---
|
||||
|
||||
# Review: t
|
||||
|
||||
## Executive Summary
|
||||
|
||||
x
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment | Reason |
|
||||
|------|-----------|--------|
|
||||
| a.mjs | deep-review | default |
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
x
|
||||
|
||||
\`\`\`json
|
||||
${json}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
test('[NOE] no engine chosen: the expected reviewer security-engine never reports, so the review is not measured and the verdict gate exits 2, never ALLOW', () => {
|
||||
assert.equal(NO_ENGINE_REVIEWER, 'security-engine');
|
||||
const expected = ['brief-conformance-reviewer', 'code-correctness-reviewer', ENGINES['code-review'].reviewer, NO_ENGINE_REVIEWER];
|
||||
const c = runContract([
|
||||
{ reviewer: 'brief-conformance-reviewer', findings: [] },
|
||||
{ reviewer: 'code-correctness-reviewer', findings: [] },
|
||||
{ reviewer: ENGINES['code-review'].reviewer, findings: [] },
|
||||
], { expectedReviewers: expected });
|
||||
assert.deepEqual(c.missing_reviewers, ['security-engine']);
|
||||
assert.ok(c.allow_blocked_by.includes('missing-reviewer:security-engine'), JSON.stringify(c.allow_blocked_by));
|
||||
assert.notEqual(c.verdict, 'ALLOW');
|
||||
const d = decideVerdict(review(c.allow_blocked_by));
|
||||
assert.equal(d.exit, 2, d.reason);
|
||||
assert.match(d.reason, /missing-reviewer:security-engine/);
|
||||
});
|
||||
|
|
@ -210,6 +210,9 @@ test('backedLine: only a security finding not from an engine can be outside (218
|
|||
const engine = own({ engine: 'security-review' });
|
||||
assert.equal(isOwnSecurityFinding(engine), false);
|
||||
assert.equal(backedLine(engine, tm), null, 'an engine finding blocks as today');
|
||||
const claudeSecurity = own({ engine: 'claude-security' });
|
||||
assert.equal(isOwnSecurityFinding(claudeSecurity), false);
|
||||
assert.equal(backedLine(claudeSecurity, tm), null, 'a Claude Security finding blocks as today (E 3.1)');
|
||||
const enginesKey = own({ rule_key: 'SECURITY_REVIEW_VULNERABILITY' });
|
||||
assert.equal(backedLine(enginesKey, tm), null, "the engines' key is never Voyage's own");
|
||||
const notSecurity = own({ rule_key: 'MISSING_TEST' });
|
||||
|
|
|
|||
|
|
@ -372,11 +372,11 @@ test('review-coordinator Pass 3 — only an engine finding is marked, and the on
|
|||
const coord = readDoc('agents', 'review-coordinator.md');
|
||||
const p3 = cut(coord, '### Pass 3', 'In `quick` mode', 'Pass 3');
|
||||
assert.match(p3, /`contradicts_brief: "<verbatim>"`/, 'the mark carries the Non-Goal or choice verbatim');
|
||||
assert.match(p3, /engine finding \(`engine: code-review` or `security-review`\)/, 'only an engine finding is marked');
|
||||
assert.match(p3, /engine finding \(`engine: code-review`, `security-review` or `claude-security`\)/, 'only an engine finding is marked');
|
||||
assert.match(p3, /only an engine finding is ever marked/);
|
||||
assert.match(p3, /only a `\/code-review` finding at the MAJOR tier and at severity MAJOR then drops to MINOR, with `brief_lowered_from: MAJOR`/,
|
||||
'the one lowering, severity MAJOR required');
|
||||
assert.match(p3, /a BLOCKER-tier, a MINOR-tier and any `\/security-review` finding only get the mark/);
|
||||
assert.match(p3, /a BLOCKER-tier, a MINOR-tier and any finding from a security engine \(`\/security-review`, Claude Security\) only get the mark/);
|
||||
assert.match(p3, /no engine finding is otherwise written below its catalogue tier/);
|
||||
assert.match(p3, /`engine` is written only on a finding whose kept Pass 1 survivor itself came from an engine payload/);
|
||||
assert.match(p3, /a reviewer-agent finding \(no `engine`\) and a finding whose `rule_key` is not in the catalogue are never marked/);
|
||||
|
|
@ -412,7 +412,7 @@ test('review-coordinator hard rules — the brief re-evaluation, the severity fl
|
|||
test('review-coordinator output — the field list and the trailing JSON example carry engine, contradicts_brief, brief_lowered_from', () => {
|
||||
const coord = readDoc('agents', 'review-coordinator.md');
|
||||
const fields = cut(coord, 'Each Findings subsection uses the', '### Trailing JSON block', 'field list');
|
||||
assert.ok(fields.includes('- `- engine: {code-review | security-review, when the finding came from an engine}`'));
|
||||
assert.ok(fields.includes('- `- engine: {code-review | security-review | claude-security, when the finding came from an engine}`'));
|
||||
assert.ok(fields.includes('- `- contradicts_brief: {verbatim Non-Goal or choice, only when set}`'));
|
||||
assert.ok(fields.includes('- `- brief_lowered_from: {MAJOR, only when lowered for the brief}`'));
|
||||
assert.ok(!fields.includes('original_severity'), 'the brief rule does not read original_severity');
|
||||
|
|
@ -605,7 +605,7 @@ test('own property — toReviewerPayload refuses engine toString with the "engin
|
|||
const r = toReviewerPayload({ engine: 'toString', findings: [] });
|
||||
assert.equal(r.ok, false, 'toString is not an engine');
|
||||
assert.equal(r.errors.length, 1);
|
||||
assert.match(r.errors[0], /^engine must be one of code-review\|security-review, got "toString"$/);
|
||||
assert.match(r.errors[0], /^engine must be one of code-review\|security-review\|claude-security, got "toString"$/);
|
||||
});
|
||||
|
||||
// ---- original_severity names what the kept finding asserted (review 08deae68) --
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue