diff --git a/bin/gstack-review-dispatch b/bin/gstack-review-dispatch index 45808e309..42c3df4d1 100755 --- a/bin/gstack-review-dispatch +++ b/bin/gstack-review-dispatch @@ -108,8 +108,9 @@ function render(): number { const diffBase = flag('--diff-base'); const checklist = flag('--checklist') ?? (role === 'red-team' ? path.join(ROOT, 'review/specialists/red-team.md') : role === 'targeted' ? path.join(ROOT, 'review/specialists/targeted.md') : role === 'specialist' && name ? path.join(ROOT, 'review/specialists', `${name}.md`) : undefined); if (checklist && (role === 'specialist' || role === 'red-team' || role === 'targeted') && !fs.existsSync(checklist)) throw new BriefError(`checklist not found: ${checklist}`); - const brief = role === 'targeted' ? readFile(checklist, 'targeted brief') : undefined; - if (role === 'targeted') { + const merged = role === 'adversarial-native' && !!flag('--classes'); + const brief = role === 'targeted' || merged ? readFile(merged ? path.join(ROOT, 'review/specialists/targeted.md') : checklist, 'targeted brief') : undefined; + if (role === 'targeted' || merged) { const base = resolveBaseBranch(process.cwd(), flag('--base')); const mb = mergeBaseWith(base); const full = diffBase ? spawnSync('git', ['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`], { encoding: 'utf8', timeout: 30_000 }).stdout?.trim() : undefined; @@ -127,11 +128,18 @@ function render(): number { if (over) { process.stderr.write(over.line + '\n'); return over.exit; } if (role === 'outside-voice') diff = branchDiff(diffBase, { exclude: flag('--exclude'), keep: keepItems }); } + let diffFile: Slots = {}; + if (diff !== undefined) { + fs.mkdirSync(out, { recursive: true, mode: 0o700 }); + const f = path.join(path.resolve(out), 'outside-voice.diff'); + fs.writeFileSync(f, diff, { mode: 0o600 }); + diffFile = { diff_file: f, diff_bytes: String(Buffer.byteLength(diff)), diff_sha256: createHash('sha256').update(diff).digest('hex') }; + } const slots: Slots = { name, diff_base: diffBase, exclude: flag('--exclude'), stack: flag('--stack'), test_fw: flag('--test-fw'), learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: checklists?.join(','), - findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), diff, - classes: flag('--classes'), brief, + findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), ...diffFile, + classes: flag('--classes'), brief, ...(merged ? { checklist: path.join(ROOT, 'review/specialists/targeted.md') } : {}), ...(flag('--disclosure') ? { disclosure: flag('--disclosure'), policy_exclude: policyItems.map(quote).join(' ') || undefined, keep: keepItems.map(quote).join(' ') || undefined } : {}), fix_list: readFile(flag('--fix-list'), 'fix list'), hunks: readFile(flag('--hunks'), 'hunks'), tests: readFile(flag('--tests'), 'tests'), callers: readFile(flag('--callers'), 'callers'), }; @@ -144,7 +152,7 @@ function render(): number { fs.writeFileSync(file, text, { mode: 0o600 }); const ledger = flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'); const sha = promptHash(text); - const targeted = brief === undefined ? {} : { classes: parseClassList(flag('--classes')!), rules: TARGETED_RULES, brief_sha: targetedBriefSha(checklist), diff_base: diffBase ?? null, patch_id: diffBase ? targetedPatchId(process.cwd(), diffBase) : 'none' }; + const targeted = brief === undefined ? {} : { classes: parseClassList(flag('--classes')!), rules: TARGETED_RULES, brief_sha: targetedBriefSha(merged ? path.join(ROOT, 'review/specialists/targeted.md') : checklist), diff_base: diffBase ?? null, patch_id: diffBase ? targetedPatchId(process.cwd(), diffBase) : 'none' }; appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text), workdir, tree, ...targeted, ...(disclosureDigest(flag('--disclosure')) ? { exclude: disclosureDigest(flag('--disclosure')) } : {}) }); process.stdout.write(`BRIEF: reader=${readerKey(role, name)} sha256=${sha} bytes=${Buffer.byteLength(text)} file=${file}\n`); return EXIT.ok; @@ -327,18 +335,20 @@ function program(): number { /** * The receipt's review_targeted record from the newest targeted dispatch: done only when its brief was sent verbatim, its output * collected, and the committed diff it was rendered on is still the one at verify (targetedPatchId; uncommitted never proves it). + * R65's merged reader (an adversarial-native brief rendered with --classes) is a targeted dispatch too (design C7). */ function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[]; rules: string; brief_sha: string; status: 'done' | 'failed'; findings: number; raised_by: string } | null { const events = readLedger(ledger); - const render = events.filter(e => e.event === 'rendered' && e.reader === 'targeted').at(-1); + const render = events.filter(e => e.event === 'rendered' && (e.reader === 'targeted' || (e.reader === 'adversarial-native' && typeof e.brief_sha === 'string'))).at(-1); if (!render) return null; - const label = rows.filter(r => r.kind === 'BRIEF' && r.reader.split('@')[0] === 'targeted').at(-1)?.reader; + const key = String(render.reader); + const label = rows.filter(r => r.kind === 'BRIEF' && r.reader.split('@')[0] === key).at(-1)?.reader; const brief = rows.filter(r => r.kind === 'BRIEF' && r.reader === label).at(-1); const output = rows.filter(r => r.kind === 'OUTPUT' && r.reader === label).at(-1); const read = typeof render.patch_id === 'string' && !['uncommitted', 'none'].includes(render.patch_id) && typeof render.diff_base === 'string' && targetedPatchId(process.cwd(), render.diff_base) === render.patch_id; const done = brief?.status === 'MATCH' && output?.status === 'MATCH' && read; - const ids = events.filter(e => e.event === 'output' && e.reader === 'targeted').at(-1)?.ids; - return { classes: (render.classes as string[]) ?? [], rules: String(render.rules), brief_sha: String(render.brief_sha), status: done ? 'done' : 'failed', findings: done && Array.isArray(ids) ? ids.length : 0, raised_by: 'targeted' }; + const ids = events.filter(e => e.event === 'output' && e.reader === key).at(-1)?.ids; + return { classes: (render.classes as string[]) ?? [], rules: String(render.rules), brief_sha: String(render.brief_sha), status: done ? 'done' : 'failed', findings: done && Array.isArray(ids) ? ids.length : 0, raised_by: key }; } /** diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index be34391f3..b8bf4ebfb 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -5,7 +5,7 @@ * gstack-review-plan --base [--pass ] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below] * [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged ] [--gated a,b] [--force a,b|all] * [--reviewed-numbers ] [--depth-floor standard|full] [--range-base ] [--pass1-depth light|standard|full] [--no-tiers] - * [--targeted-ran ] [--grant ] [--json] + * [--targeted-ran ] [--grant ] [--fix-checks ] [--pr ] [--json] * gstack-review-plan --fix-check-prompt --fix-list --hunks --tests [--callers ] * * Reads the diff against the merge base with (committed, tracked uncommitted @@ -31,6 +31,8 @@ import { runScriptSync } from '../lib/script-invocation'; import { collectCommit, collectLive } from '../lib/review-collect'; import { excludeLines, reviewExclude, type Exclusion } from '../lib/review-exclude'; import { isUnavailable } from '../lib/ship-policy'; +import { reviewPolicy } from '../lib/ship-policy-review'; +import { platformReviewAt } from '../lib/review-platform'; import { loadPolicyAt } from '../lib/ship-policy-at'; import { classifyDiff, diffCommand, keepCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth'; import { classList, targetedBriefSha, targetedClasses, targetedPatchId } from '../lib/review-targeted'; @@ -66,9 +68,16 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do its ask or scope naming review_loop_grant and the stop head's sha, at or after --range-base and at or before HEAD; needs --range-base): the stop after a round-2 full pass with a fixed blocking finding or a gate-class edit plans that fix-check instead; a fix-check never earns another; any other row is refused (exit 3) + --fix-checks fix-checks already run in this invocation (R65's lean loop: the owner's grant adds one only after the first) + --pr the PR whose platform review counts under review.platform on (default: this branch's PR) --fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files -Depth (R51, risk not size): full on gate class, a risk class (money, auth, secret, security, data) or any DEPTH_ERROR; +Mode (R65, the base policy's review key): lean (default) runs one reader: risk class gets the merged reader on review.risk_model +(the native adversarial brief plus the checklists, the Red Team and the targeted brief), non-risk code the single reader at the +lane's reasoning (cross-family when it publishes numbers), docs and tests the core read; one fix-check after fixes, never more +than 2 rounds. review.panel risk|all keeps today's panel there. review.platform on: a light diff with no numeric claims whose PR +carries capy-ai[bot]'s review at HEAD plans NEXT: platform and a planned SKIP for every reader. +Depth (R51, risk not size): full on gate class, a risk class (money, auth, secret, security, data, review.risk_paths) or any DEPTH_ERROR; standard only by --depth-floor or a raise; otherwise light. TARGETED: the money, gate, auth and secret classes the diff touches; any class dispatches the targeted reader at every depth. NEXT: full, scoped (a full pass on the range: fix-check, Red Team, native adversarial), fix-check, voices (gate class: Step 11 before any stop, @@ -101,6 +110,8 @@ function main(): number { if (!['none', 'full', 'fix-check'].includes(last)) { process.stderr.write('gstack-review-plan: --last must be none, full or fix-check\n'); return EXIT.usage; } const pass1Depth = (flag('--pass1-depth') ?? 'full') as Depth; if (!['light', 'standard', 'full'].includes(pass1Depth)) { process.stderr.write('gstack-review-plan: --pass1-depth must be light, standard or full\n'); return EXIT.usage; } + const fixChecks = Number(flag('--fix-checks') ?? (last === 'fix-check' ? '1' : '0')); + if (!Number.isInteger(fixChecks) || fixChecks < 0) { process.stderr.write('gstack-review-plan: --fix-checks needs an integer >= 0\n'); return EXIT.usage; } const pass = Number(flag('--pass') ?? '0'); if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; } const floor = flag('--depth-floor') as Depth | undefined; @@ -149,6 +160,9 @@ function main(): number { const baseSha = collected.registry.sha ?? git(['rev-parse', '--verify', '-q', `${ref}^{commit}`])?.trim() ?? ''; const policy = baseSha ? loadPolicyAt(root, baseSha, ref) : null; const declares = !!policy && !isUnavailable(policy) && policy.policy.review_exclude_paths.length > 0; + const review = reviewPolicy(policy && !isUnavailable(policy) ? policy.policy : null); + const riskPaths = review.risk_paths.length ? { riskPaths: review.risk_paths } : {}; + const platform = review.platform === 'on' ? platformReviewAt(root, head, { pr: flag('--pr') }) : null; let tagged: Set | null = null; let gateTagged: number | null; if (declares) { @@ -176,7 +190,7 @@ function main(): number { else if (git(['diff', '--quiet', 'HEAD', '--']) === null || collected.untracked.length) fixRange = unread('uncommitted or untracked edits: commit the fixes first'); else { const commit = collectCommit(root, resolved, 'HEAD'); - const range = classifyDiff({ ...commit, registry, scope: scopeOf([resolved, '--head', 'HEAD']), gateTagged: gateTagsOf([resolved]), ...(active ? { excluded: active.excluded } : {}) }); + const range = classifyDiff({ ...commit, registry, scope: scopeOf([resolved, '--head', 'HEAD']), gateTagged: gateTagsOf([resolved]), ...riskPaths, ...(active ? { excluded: active.excluded } : {}) }); fixRange = range.errors.length ? unread(range.errors.map(e => `${e.code} ${e.input}`).join('; ')) : !commit.paths.length ? unread('the fix range is empty: commit the fix after the range base') : { base: rangeBase, runtime_lines: range.runtimeLines, ...(range.class === 'gate' ? { touched_gate: true as const } : {}) }; @@ -186,26 +200,28 @@ function main(): number { const patch = collected.mergeBase ? targetedPatchId(process.cwd(), collected.mergeBase) : 'none'; const brief = targetedBriefSha(); const plan = planReview({ - ...collected, ...exclusionInput, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(kept(added)), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host', + ...collected, ...exclusionInput, ...riskPaths, shape: { panel: review.panel, readerModel: review.reader_model, platform: review.platform, head, platformReview: platform?.ok ? platform.review : null }, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(kept(added)), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host', criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'), - history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines, granted: grant }, + history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines, granted: grant, fixChecks }, }); const gateScan = { tagged: gateTagged, head }; if (has('--json')) { const { runtimeLines, stepDepth, depthReasons, ...rest } = plan; - process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings, + process.stdout.write(JSON.stringify({ ...rest, review_policy: review, platform_review: platform ? (platform.ok ? platform.review : { none: platform.reason }) : null, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings, ...(exclusion.state === 'head-only' ? { review_exclude: exclusion } : active ? { review_exclude: { state: 'active', ...active.record, specs: active.specs, keep: active.keep, kept_files: active.kept, content: active.content, references: active.references } } : {}) }) + '\n'); return EXIT.ok; } const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`, `TARGETED: classes=${classList(targeted.classes)} rules=${targeted.rules} brief=${brief} targeted_patch_id=${patch}${targeted.reasons.length ? ` reasons=${targeted.reasons.join('; ')}` : ''}`]; out.push(...excludeLines(exclusion, base)); + out.push(`REVIEW_MODE: ${plan.mode} panel=${review.panel} reader_model=${review.reader_model} platform=${review.platform}`); + if (platform) out.push(platform.ok ? `PLATFORM_REVIEW: ${platform.review.id} reviewer=${platform.review.reviewer} head=${platform.review.head}` : `PLATFORM_REVIEW: none (${platform.reason})`); if (fixRange) out.push(`FIX_RANGE: ${fixRange.runtime_lines === null ? `unread (${fixRange.error})` : `runtime_lines=${fixRange.runtime_lines}`} base=${fixRange.base}`); out.push(reviewPlanLine(plan, pass + (plan.next.step === 'full' || plan.next.step === 'scoped' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`); for (const e of plan.errors) out.push(`DEPTH_ERROR: ${e.code} input=${e.input} detail=${e.detail}; fix: ${e.repair}${e.result ? ` (${e.result})` : ''}`); for (const d of plan.dispatches) { const key = d.name.split(' ')[0]; - out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}${d.checklists ? ` checklists=${d.checklists.join(',')}` : ''}${d.reason ? ` reason=${d.reason}` : ''}`, `DIFF_CMD: ${key} ${diffCommand(d)}`); + out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}${d.model ? ` model=${d.model}` : ''}${d.checklists ? ` checklists=${d.checklists.join(',')}` : ''}${d.classes ? ` classes=${d.classes.join(',')}` : ''}${d.reason ? ` reason=${d.reason}` : ''}`, `DIFF_CMD: ${key} ${diffCommand(d)}`); if (!d.exclude.length) out.push(`DIFF_EXCLUDE: ${key} none`); const keepCmd = keepCommand(d); if (keepCmd) out.push(`DIFF_KEEP: ${key} ${keepCmd}`); diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index 886976f34..eb71c05a2 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -99,7 +99,7 @@ const CERTIFIED_OUTSIDE = { model_id: 'openai/gpt-6.1-sol', reasoning_mode: 'med const voiceFile = input.out.replace(/\/+$/, '') + '/voice-models.json'; let voices = { laneModel: null, readers: {} }; let laneFamily = family(input.laneModel); -const modelOf = (r) => r.model || (r.reader in voices.readers ? voices.readers[r.reader] || undefined +const modelOf = (r) => r.model || (r.cross_family ? (input.models || {})['outside-voice'] || (laneFamily === 'anthropic' ? CERTIFIED_OUTSIDE : undefined) : undefined) || (r.reader in voices.readers ? voices.readers[r.reader] || undefined : (input.models || {})[r.role === 'specialist-batched' ? 'specialist' : r.role] || (r.role === 'outside-voice' && laneFamily === 'anthropic' ? CERTIFIED_OUTSIDE : undefined) || (REASONING[r.effort] && input.laneModel ? { model_id: input.laneModel, reasoning_mode: REASONING[r.effort] } : undefined)); diff --git a/lib/review-brief.ts b/lib/review-brief.ts index e1670f04d..7fe0aa7f8 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -134,12 +134,21 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string { ['{red-team checklist path}', need(slots, 'checklist')], ['', diffBase]], role); return fixtures(`${t.framing}\n\n${t.fixtures}\n\n${body}`); } - case 'adversarial-native': - return fixtures(fill(t.native, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role)); + case 'adversarial-native': { + const native = fixtures(fill(t.native, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role)); + const lists = (slots.checklists ?? '').split(',').map(s => s.trim()).filter(Boolean); + const merged = lists.length ? `${native}\n\nIn the same read, apply each checklist at ${lists.join(', ')} to the diff, and tag each finding's "specialist" with the name of the checklist it came from (adversarial for your own).` : native; + if (!slots.classes) return merged; + const classes = parseClassList(slots.classes); + if (!classes?.length) throw new BriefError(`slot classes must list money, gate, auth or secret, got ${JSON.stringify(slots.classes)}`); + try { return `${targetedBrief(need(slots, 'brief'), classes)}\n\n${merged}\n\nApply the targeted brief above (${need(slots, 'checklist')}: ${classes.join(', ')}) in the same read; your reply is this diff's targeted pass.`; } catch (e) { throw e instanceof BriefError ? e : new BriefError((e as Error).message); } + } case 'outside-voice': { const filled = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role); const body = slots.policy_exclude ? filled.split('git diff "$DIFF_BASE".').join(`git diff "$DIFF_BASE" -- . ${slots.policy_exclude}${slots.keep ? ` and git diff "$DIFF_BASE" -- ${slots.keep}` : ''}.`) : filled; - return [body, '', ...(slots.disclosure ? [slots.disclosure, ''] : []), `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n'); + const head = [body, '', ...(slots.disclosure ? [slots.disclosure, ''] : []), `## Branch diff (git diff ${diffBase})`]; + if (slots.diff_file) return [...head, `The supplied branch diff is the file ${slots.diff_file} (${need(slots, 'diff_bytes')} bytes, sha256 ${need(slots, 'diff_sha256')}). Read it from disk; it is the whole diff under review.`].join('\n'); + return [...head, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n'); } case 'targeted': { const classes = parseClassList(need(slots, 'classes')); diff --git a/lib/review-depth.ts b/lib/review-depth.ts index e8751cc8e..2373e81fe 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -13,14 +13,14 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { ResultCodeName } from './result-codes'; import { matchesAny } from './ship-policy'; -import { decodeCharRefs, matchRisk, RISK_RULES, SVG_PATH, svgActive, type RiskClass } from './review-risk'; +import { decodeCharRefs, DOC_SECRET_LINE, matchRisk, RISK_CLASSES, RISK_RULES, SVG_PATH, svgActive, type RiskClass } from './review-risk'; export type DiffClass = 'docs' | 'tests' | 'code' | 'gate'; export type PathClass = 'docs' | 'tests' | 'code'; export type Depth = 'light' | 'standard' | 'full'; export type Effort = 'light' | 'medium' | 'full' | 'host'; export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check' | 'targeted'; -export type Step = 'full' | 'scoped' | 'fix-check' | 'voices' | 'done' | 'stop'; +export type Step = 'full' | 'scoped' | 'fix-check' | 'voices' | 'platform' | 'done' | 'stop'; export const SPECIALISTS = ['testing', 'maintainability', 'security', 'performance', 'data-migration', 'api-contract', 'design', 'simplification', 'state-machines'] as const; export type Specialist = (typeof SPECIALISTS)[number]; @@ -63,6 +63,8 @@ export const isGatePath = (p: string) => GATE_PATH.some(r => r.test(p)); export const isAgentProse = (p: string) => AGENT_PROSE.some(r => r.test(p)); export const isDataConfig = (p: string) => DATA_CONFIG.some(r => r.test(p)); +/** Binary data and media (HARN-1: committed sqlite ledgers git cannot count): data by extension wherever they sit, never code, consumed or not. */ +export const BINARY_DATA_PATH = /\.(sqlite3?|db|duckdb|parquet|arrow|feather|avro|orc|npy|npz|h5|hdf5|pdf|mp3|mp4|mov|wav|webm)$/i; /** Data files: JSON, CSV, TSV and raster images. YAML and TOML are never data here: deploy configs and API specs stay code. An SVG is data only as a drawing (svgDrawing); executable SVG stays code. */ export const DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$/i; /** Data-shaped files tools read by convention, with no code naming them: always code. */ @@ -153,7 +155,7 @@ export function svgDrawing(text: string): boolean { /** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */ export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({ RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT, SVG_ELEMENTS: [...SVG_ELEMENTS], - patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), + patterns: Object.entries({ BINARY_DATA_PATH: [BINARY_DATA_PATH], GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), })).digest('hex').slice(0, 16); /** @@ -229,13 +231,16 @@ export interface ClassInput { removed?: Record; /** Paths the base policy's review_exclude_paths leave out of every reader's diff (lib/review-exclude.ts): classified as docs. */ excluded?: ReadonlySet; + /** The base policy's review.risk_paths: a code path they match is risk class `policy`; data and binary files under them never are. */ + riskPaths?: readonly string[]; } export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null; risk: RiskClass[] } const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200); const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p); const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && (dataCandidate(p, i.root) || (!!i.drawings?.includes(p) && svgCandidate(p, i.root))); -const pathClass = (i: ClassInput, p: string): PathClass => (i.excluded?.has(p) || (i.stampOnly ?? []).includes(p) || unconsumedData(i, p) ? 'docs' : classifyPath(p, i.root)); +const binaryData = (i: ClassInput, p: string) => BINARY_DATA_PATH.test(p) && classifyPath(p, i.root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p)); +const pathClass = (i: ClassInput, p: string): PathClass => (i.excluded?.has(p) || (i.stampOnly ?? []).includes(p) || unconsumedData(i, p) || binaryData(i, p) ? 'docs' : classifyPath(p, i.root)); /** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */ export function classifyDiff(i: ClassInput): ClassResult { @@ -266,8 +271,10 @@ export function classifyDiff(i: ClassInput): ClassResult { for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; } if (i.registry?.error) errors.push(i.registry.error); } - const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true' }) : new Map(); - const risk = [...riskHits.keys()]; + const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true', ...(i.riskPaths?.length ? { isRiskPath: (p: string) => matchesAny(i.riskPaths!, p) && !DATA_PATH.test(p) } : {}) }) : new Map(); + const docSecret = i.paths.find(p => pathClass(i, p) === 'docs' && DOC_PATH.test(p) && (i.added?.[p] ?? []).some(l => DOC_SECRET_LINE.some(r => r.test(l)))); + if (docSecret && !riskHits.has('security')) riskHits.set('security', `docs that can print a credential: ${docSecret}`); + const risk = [...riskHits.keys()].sort((a, b) => RISK_CLASSES.indexOf(a) - RISK_CLASSES.indexOf(b)); reasons.push(...[...riskHits.values()].map(r => `risk: ${r}`)); if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null, risk }; let runtimeLines = 0; @@ -351,6 +358,10 @@ export interface History { adversarialRan?: boolean; /** The approval id of the owner's recorded grant of one scoped fix-check after round 2 (review_loop_exception). */ granted?: string; + /** R65's lean table (planReview sets it from the base policy's review.panel): one reviewer, then at most one fix-check. */ + lean?: boolean; + /** Fix-checks already run in this invocation (lean: the grant adds one only after the first). */ + fixChecks?: number; } export interface NextStep { step: Step; reason: string } @@ -367,13 +378,31 @@ export interface NextStep { step: Step; reason: string } * one fix-check; a fix-check never earns another, so the grant cannot repeat. */ export function nextStep(h: History, depth?: Depth): NextStep { - const n = loopStep(h, depth); + const n = h.lean ? leanStep(h, depth) : loopStep(h, depth); if (n.step !== 'stop') return n; - if (h.gate && !h.adversarialRan) return { step: 'voices', reason: `gate class: Step 11's voices (adversarial, outside, targeted) run before the owner's exit, then ask again with --adversarial-ran (${n.reason})` }; - if (h.granted && !h.blockingOpen && h.last === 'full' && h.fullPasses === FULL_PASS_CAP) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check after round ${FULL_PASS_CAP} (review_loop_exception), never a fourth round` }; + if (h.gate && !h.adversarialRan) return { step: 'voices', reason: `gate class: Step 11's ${h.lean ? 'one reader' : 'voices (adversarial, outside, targeted)'} run${h.lean ? 's' : ''} before the owner's exit, then ask again with --adversarial-ran (${n.reason})` }; + if (!h.lean && h.granted && !h.blockingOpen && h.last === 'full' && h.fullPasses === FULL_PASS_CAP) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check after round ${FULL_PASS_CAP} (review_loop_exception), never a fourth round` }; return n; } +/** + * R65's lean table (GSTA-258): pass 1 is the one reviewer; any fix on a risk-class diff, and a fixed blocker or a gate-class + * edit on any diff, earns one scoped fix-check (round 2, the cap); below-floor fixes on a light diff ship as notes. After the + * fix-check a blocker or gate-class edit stops for the owner, whose recorded grant adds one more fix-check, never two. + */ +function leanStep(h: History, depth?: Depth): NextStep { + if (h.last === 'none') return { step: 'full', reason: 'pass 1: one reviewer reads the whole diff (R65)' }; + if (h.last === 'fix-check') { + if (!h.blockingOpen && !h.fixedBlocking && !h.touchedGate) return { step: 'done', reason: h.fixedBelow ? 'round cap 2 reached: the below-floor fixes ship as notes in the PR body and receipt' : 'the fix-check found nothing blocking' }; + if (h.granted && !h.blockingOpen && (h.fixChecks ?? 1) < 2) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check after round ${FULL_PASS_CAP} (review_loop_exception), never a second` }; + return { step: 'stop', reason: `the fix-check ${h.blockingOpen || h.fixedBlocking ? 'found a blocking finding' : 'edited a gate-class path'}: name it for the owner, whose grant may add one scoped fix-check, never a third round` }; + } + if (h.blockingOpen) return { step: 'stop', reason: 'a blocking finding is still open in fix scope: name it for the owner' }; + if (!h.fixedBlocking && !h.fixedBelow && !h.touchedGate) return { step: 'done', reason: 'the step applied no edits' }; + if (depth === 'light' && !h.fixedBlocking && !h.touchedGate) return { step: 'done', reason: 'light depth: the below-floor fixes ship as notes in the PR body and receipt without a fix-check (R65)' }; + return { step: 'fix-check', reason: `${h.fixedBlocking ? 'a blocking finding was fixed' : h.touchedGate ? 'a fix touched a gate-class path' : 'below-floor fixes on a risk-class diff'}: one scoped fix-check reads the touched hunks (round 2, the cap)` }; +} + function loopStep(h: History, depth?: Depth): NextStep { if (h.last === 'none') return { step: 'full', reason: 'pass 1 reviews the whole diff' }; const fixed = !!(h.fixedBlocking || h.fixedBelow); @@ -421,14 +450,20 @@ export interface PlanInput extends ClassInput { targeted?: { classes: readonly string[]; ran?: boolean }; /** The base policy's exclusion: one `:(top,exclude,glob)` pathspec per applied pattern, added to every reader, and the kept files under them as `:(top,literal)` pathspecs. */ exclusion?: { specs: readonly string[]; keep: readonly string[] }; + /** The base policy's review key and the platform evidence at HEAD; absent plans today's panel (review.panel all). */ + shape?: ReviewShape; } +/** How much review a repo runs (lib/ship-policy-review.ts) plus HEAD and the platform review found at it (lib/review-platform.ts). */ +export interface ReviewShape { panel: 'off' | 'risk' | 'all'; readerModel: string; platform: 'off' | 'on'; head: string; platformReview: { id: string; head: string; reviewer: string } | null } /** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what the batched specialist reads. */ -export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string; keep?: string[] } +export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string; keep?: string[]; model?: string; classes?: string[] } +/** lean: R65's one reviewer; panel: today's panel (review.panel); platform: the platform review at HEAD stands in for every reader. */ +export type ReviewMode = 'lean' | 'panel' | 'platform'; export interface ReviewPlan extends DepthResult { /** The depth this step runs at: light raised to standard by a fixed blocker, a CRITICAL or an open blocker. */ stepDepth: Depth; depthReasons: string[]; - next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[]; + next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[]; mode: ReviewMode; } const scopeWants: Partial) => boolean>> = { @@ -506,7 +541,10 @@ export function planReview(i: PlanInput): ReviewPlan { const c = classifyDiff(i); const depth = decideDepth(c, c.runtimeLines ?? (CLASS_RANK[c.class] >= CLASS_RANK.code ? i.diffLines : 0), i.floor); const { class: cls, runtimeLines } = depth; - const next = nextStep({ ...i.history, gate: cls === 'gate', adversarialRan: !!i.adversarialRan }, depth.depth); + const sh = i.shape; + const panel = !sh || sh.panel === 'all' || (sh.panel === 'risk' && riskClass(c, depth)); + const next = nextStep({ ...i.history, gate: cls === 'gate', adversarialRan: !!i.adversarialRan, lean: !panel }, depth.depth); + if (!panel) return leanPlan(i, c, depth, next); const h = i.history; const raise = depth.depth === 'light' && (h.fixedBlocking || h.blockingOpen || i.criticalFound); const stepDepth: Depth = raise ? 'standard' : depth.depth; @@ -576,7 +614,60 @@ export function planReview(i: PlanInput): ReviewPlan { if (i.targeted.ran) skipped.push('targeted (already read this product patch)'); else add('targeted', 'targeted', ROLE_EFFORT.targeted); } - return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped }; + return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped, mode: 'panel' }; +} + +/** + * R65's lean dispatch (docs/review-policy.md#review-depth): pass 1 runs one native adversarial reader at the lane's reasoning, on + * review.reader_model (the lane's model by default: FOUN-32, ggstack#52). A risk-class diff under review.panel off gets the merged + * reader: the gate-class checklists, the Red Team checklist and the targeted brief for its classes; a non-risk code diff gets the + * code-class checklists and the Red Team. Docs and tests keep the core read. A docs or tests diff with no numeric, results or prereg + * claims, under review.platform on, with the platform review at HEAD, runs no reader at all (mode platform). + */ +/** Risk class read from the paths and lines (gate class, an R51 or review.risk_paths class, a classifier error), or a floor above light (FOUN-64 item 7: never the recorded review_depth). */ +const riskClass = (c: ClassResult, depth: DepthResult) => c.class === 'gate' || c.risk.length > 0 || c.errors.length > 0 || depth.depth !== 'light'; +/** Paths that carry results or preregistration claims: never the platform depth (FOUN-64 item 2). */ +const CLAIM_PATH = /(^|\/|[-_.])(results?|prereg|preregistration|benchmarks?|evals?|scorers?|checkers?)([-_./]|$)/i; + +function leanPlan(i: PlanInput, c: ClassResult, depth: DepthResult, next: NextStep): ReviewPlan { + const sh = i.shape!; + const risky = riskClass(c, depth); + const skipped: string[] = []; + const dispatches: Dispatch[] = []; + const x = i.exclusion; + const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial = {}) => + dispatches.push({ role, name, effort, exclude: x ? [...exclude, ...x.specs] : exclude, ...extra, ...(x?.keep.length ? { keep: [...x.keep] } : {}) }); + const plan = (n: NextStep, mode: ReviewMode): ReviewPlan => ({ ...depth, stepDepth: depth.depth, depthReasons: depth.reasons, reasons: c.reasons, next: n, numbers: i.numbers, dispatches, skipped, mode }); + const pr = sh.platformReview; + const platformFit = !risky && depth.runtimeLines === 0 && !i.numbers.publishes && !i.paths.some(p => CLAIM_PATH.test(p)); + if (next.step === 'full' && platformFit && sh.platform === 'on' && pr && pr.head === sh.head) { + const why = `planned skip: platform review ${pr.id} by ${pr.reviewer} at ${pr.head.slice(0, 12)}`; + for (const r of ['core checklist', 'specialists', 'red-team', 'adversarial-native', 'outside-voice', 'targeted']) skipped.push(`${r} (${why})`); + return plan({ step: 'platform', reason: `review.platform on: the platform review ${pr.id} read HEAD ${pr.head.slice(0, 12)}, so /ship runs no reader (Step 11.5 binds the planned skip)` }, 'platform'); + } + if (sh.platform === 'on' && next.step === 'full' && !risky) skipped.push(`platform depth (${depth.runtimeLines > 0 ? 'runtime code' : i.numbers.publishes ? 'the diff publishes numbers' : !platformFit ? 'a results, prereg, eval or checker path' : pr ? `the platform review read ${pr.head.slice(0, 12)}, not HEAD` : 'no platform review at HEAD'}: the light read runs)`); + const classes = i.targeted?.classes.length && !i.targeted.ran ? { classes: [...i.targeted.classes] } : {}; + const docsAndOutputs = i.paths.filter(p => !i.excluded?.has(p) && ((pathClass(i, p) === 'docs' && !(i.numbers.publishes && DOC_PATH.test(p))) || isOutput(i, p))).map(excludeSpec); + const reader = () => risky + ? add('adversarial-native', 'merged', 'full', [], { checklists: [...batchChecklists('gate', i, skipped), 'red-team'], model: sh.readerModel, ...classes }) + : add('adversarial-native', 'single', 'full', docsAndOutputs, { checklists: [...batchChecklists('code', i, skipped), 'red-team'], model: sh.readerModel, ...classes }); + if (next.step === 'full' && (risky || depth.runtimeLines > 0)) { + reader(); + skipped.push(`specialists, red-team and outside-voice as their own readers (R65: the one ${risky ? 'risk-class' : 'light'} reader carries them)`); + } else if (next.step === 'full') { + for (const n of SPECIALISTS.filter(forcedBy(i))) add('specialist', n, i.tiers === false ? 'host' : ROLE_EFFORT.specialist); + if (i.numbers.publishes) add('outside-voice', 'outside-voice', 'host'); + else skipped.push(`outside-voice (${depth.class} class, no numeric claims)`); + } else if (next.step === 'voices') reader(); + else if (next.step === 'fix-check') { + add('fix-check', 'fix-check', 'full'); + if (i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint) add('outside-voice', 'outside-voice (numbers changed)', 'host'); + } + if (next.step === 'fix-check' && i.targeted?.classes.length) { + if (i.targeted.ran) skipped.push('targeted (already read this product patch)'); + else add('targeted', 'targeted', ROLE_EFFORT.targeted); + } + return plan(next, 'lean'); } export function reviewPlanLine(p: ReviewPlan, pass: number): string { diff --git a/lib/review-pass.ts b/lib/review-pass.ts index 057412054..0b68dd81a 100644 --- a/lib/review-pass.ts +++ b/lib/review-pass.ts @@ -11,6 +11,7 @@ * persisted before its subagent is created and its path right after, so a resumed program * reconciles by deterministic title and never doubles a reader. */ +import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { BriefError, briefFile, checkoutRoot, checkoutTree, promptHash, renderBrief, withWorkspace, type BriefRole, type SectionTexts, type Slots } from './review-brief'; @@ -31,6 +32,8 @@ export interface PassReader { classes?: string[]; diff_base?: string; /** An audit reader: its prompt is a file the parent wrote from its section; its output is returned, never collected. */ prompt_file?: string; model?: PassModel; + /** The plan's model=cross-family (R65's risk reader or a numbers diff's one reader): the program runs it on the outside voice's model. */ + cross_family?: boolean; file?: string; sha256?: string; bytes?: number; chunks?: number; findings_sha256?: string; /** With the base policy's review_exclude_paths active: its `:(top,exclude,glob)` items, shell-quoted (the fixture command's slot), and the kept files' `:(top,literal)` items. */ policy_exclude?: string; keep?: string[]; @@ -103,6 +106,8 @@ export function buildManifest(o: { readers.push({ reader, stage, title: `ship ${o.pass} ${reader}`, state: 'planned', role, name, effort: d.effort, exclude, checklists: d.checklists, ...(policy.length ? { policy_exclude: policy.map(shellQuote).join(' ') } : {}), ...(d.keep?.length ? { keep: d.keep } : {}), ...(role === 'targeted' ? { classes: [...(o.targetedClasses ?? [])], diff_base: o.mergeBase ?? o.diffBase } : {}), + ...(role === 'adversarial-native' && d.classes?.length ? { classes: [...d.classes], diff_base: o.mergeBase ?? o.diffBase } : {}), + ...(d.model === 'cross-family' ? { cross_family: true } : d.model && d.model !== 'lane' ? { model: { model_id: d.model } } : {}), ...(role === 'red-team' && o.rangeBase ? { diff_base: o.rangeBase } : {}) }); } for (const e of o.extras ?? []) { @@ -176,14 +181,19 @@ export function renderStage(m: Manifest, stage: Stage, t: SectionTexts, diffFor: const specialists = m.readers.reduce((n, x) => n + (x.role === 'specialist' ? 1 : x.role === 'specialist-batched' ? (x.checklists ?? []).filter(c => c !== 'red-team').length : 0), 0); Object.assign(slots, { checklist: path.join(m.checklist_root, 'review/specialists/red-team.md'), findings, specialists: String(specialists) }); } - if (role === 'outside-voice') slots.diff = diffFor(m.diff_base, r); + if (role === 'outside-voice') { + const diff = diffFor(m.diff_base, r); + const diffFile = path.join(briefs, 'outside-voice.diff'); + fs.writeFileSync(diffFile, diff, { mode: 0o600 }); + Object.assign(slots, { diff_file: diffFile, diff_bytes: String(Buffer.byteLength(diff)), diff_sha256: createHash('sha256').update(diff).digest('hex') }); + } if (role === 'fix-check') { const read = (f: string | undefined, what: string) => { if (!f) return undefined; try { return fs.readFileSync(f, 'utf8'); } catch { throw new BriefError(`cannot read the fix-check ${what} ${f}`); } }; const fc = m.fix_check!; Object.assign(slots, { fix_list: read(fc.fix_list, 'fix list'), hunks: read(fc.hunks, 'hunks'), tests: read(fc.tests, 'tests'), callers: read(fc.callers, 'callers') }); } let targeted = {}; - if (role === 'targeted') { + if (role === 'targeted' || (role === 'adversarial-native' && r.classes?.length)) { const checklist = path.join(m.checklist_root, 'review/specialists/targeted.md'); let brief: string; try { brief = fs.readFileSync(checklist, 'utf8'); } catch { throw new BriefError(`cannot read the targeted brief ${checklist}`); } diff --git a/lib/review-platform.ts b/lib/review-platform.ts new file mode 100644 index 000000000..e9949d4e3 --- /dev/null +++ b/lib/review-platform.ts @@ -0,0 +1,30 @@ +/** + * review-platform — the exact-head evidence that a platform review read a PR (design C6, FOUN-32 on founderstar#496). + * Capy's automatic review posts a pull-request review by capy-ai[bot] whose commit_id is the head it read; that review's + * id is the run id the plan's planned skip and the receipt's review_depth platform record. Anything else (no PR, no such + * review, a review at another commit, gh unavailable) is no evidence, and the single light reader runs. + */ +import { spawnSync } from 'node:child_process'; + +export const PLATFORM_REVIEWER = 'capy-ai[bot]'; +export interface PlatformReview { id: string; head: string; reviewer: string } + +function gh(root: string, args: string[]): { ok: boolean; out: string } { + const r = spawnSync('gh', args, { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }); + return { ok: r.status === 0, out: (r.stdout ?? '').trim() }; +} + +/** The newest submitted platform review of the PR (`pr`, or the current branch's) at `head`; `id`, when given, must be that review. */ +export function platformReviewAt(root: string, head: string, o: { pr?: string; id?: string } = {}): { ok: true; review: PlatformReview } | { ok: false; reason: string } { + const view = gh(root, ['pr', 'view', ...(o.pr ? [o.pr] : []), '--json', 'number']); + const pr = view.ok ? (JSON.parse(view.out || '{}') as { number?: number }).number : undefined; + if (!pr) return { ok: false, reason: o.pr ? `gh cannot read PR ${o.pr}` : 'no PR for this branch yet' }; + const list = gh(root, ['api', '--paginate', '--slurp', `repos/{owner}/{repo}/pulls/${pr}/reviews`]); + if (!list.ok) return { ok: false, reason: `gh cannot list the reviews of PR #${pr}` }; + let reviews: Array<{ id?: number; user?: { login?: string }; commit_id?: string; state?: string }>; + try { reviews = (JSON.parse(list.out) as unknown[]).flat() as typeof reviews; } catch { return { ok: false, reason: `PR #${pr}'s reviews are not JSON` }; } + const at = reviews.filter(r => r.user?.login === PLATFORM_REVIEWER && r.state !== 'PENDING' && r.commit_id?.toLowerCase() === head.toLowerCase() && r.id !== undefined); + const hit = o.id ? at.find(r => String(r.id) === o.id) : at.at(-1); + if (!hit) return { ok: false, reason: o.id ? `review ${o.id} on PR #${pr} is not a ${PLATFORM_REVIEWER} review at ${head.slice(0, 12)}` : `no ${PLATFORM_REVIEWER} review of PR #${pr} at ${head.slice(0, 12)}` }; + return { ok: true, review: { id: String(hit.id), head: head.toLowerCase(), reviewer: PLATFORM_REVIEWER } }; +} diff --git a/lib/review-risk.ts b/lib/review-risk.ts index b6c5b92f9..f34203d13 100644 --- a/lib/review-risk.ts +++ b/lib/review-risk.ts @@ -12,7 +12,7 @@ */ import { createHash } from 'node:crypto'; -export const RISK_CLASSES = ['money', 'auth', 'secret', 'security', 'data'] as const; +export const RISK_CLASSES = ['money', 'auth', 'secret', 'security', 'data', 'eval', 'policy'] as const; export type RiskClass = (typeof RISK_CLASSES)[number]; /** Metering and charge words (GST-850: gbrain-evals #289 changed a metering proxy and billing clients with none of the older tokens); they also count on a line. */ @@ -68,20 +68,41 @@ export const decodeCharRefs = (s: string) => s.replace(/&#(?:[xX]0*([0-9a-fA-F]+ return c <= 0x10ffff ? String.fromCodePoint(c) : ''; }); export const svgActive = (text: string) => { const d = decodeCharRefs(text); const squeezed = d.replace(/[\s\0-\x1f]+/g, ''); return SVG_ACTIVE.some(r => r.test(d) || r.test(squeezed)); }; +/** Eval scorers, judges and graders (FOUN-32 §4): code that decides a published result is risk class eval, by path. */ +const EVAL_PATH_TOKENS = ['scorer', 'scorers', 'scoring', 'judge', 'judges', 'grader', 'graders', 'grade', 'grading', 'rubric', 'rubrics']; const DATA_PATH_TOKENS = ['pii', 'gdpr', 'ccpa', 'privacy', 'personal', 'account', 'accounts', 'customer', 'customers', 'cookie', 'cookies', 'persistence', 'storage', 'database']; export const DATA_LINE = [ /\bDELETE\s+FROM\b/, /\b(?:drop\s+(?:table|column|database|schema)|truncate\s+table)\b/i, /\bdelete\s+from\s+[\w."`]+\s*(?:where\b|;|$|["'`)])/i, /\.(?:deleteMany|destroyAll|bulkDelete)\s*\(/, /\b(?:destroy_all|delete_all)\b/, ]; +/** + * Data and config files (HARN-1, harness-evals#231): judged by their path only, so a lease_usd value in a VM record never names money. + * A key in a config file still names a secret. + */ +export const CONFIG_DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|ya?ml|toml|ini|cfg|conf|env|properties|xml|plist)$/i; +/** Analysis scripts that live with their results (docs/benchmarks/.../results/cost-intervals.py): judged by their path, which names no risk class. */ +export const STATS_SCRIPT = /(^|\/)(docs|results|benchmarks?|analysis|notebooks?)\/(?:[^/]+\/)*[^/]+\.(py|r|jl|ipynb)$/i; + +/** + * Docs that can print a credential (gbrain#8417: `mcp get gbrain` shows a stored bearer token): an Authorization header, a + * bearer value, a key-shaped literal, an environment key set to a value, or a get/show command for auth config. Read on the + * added lines of docs paths, which matchRisk never sees, and counted as security (FOUN-64 item 4). + */ +export const DOC_SECRET_LINE = [ + /\bAuthorization\s*:\s*\S/i, /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/, /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}|\bgh[pousr]_[A-Za-z0-9]{20,}|\bxox[abposr]-[A-Za-z0-9-]{10,}|\bAKIA[0-9A-Z]{16}\b/, + new RegExp(String.raw`\b${KEY_NAME}\s*[=:]\s*['"]?[^\s'"$<{]{8,}`), + /\b(?:mcp|auth|config|credentials?|tokens?|secrets?|keys?|login)\s+(?:get|show|print|view|cat|token)\b/i, /\b(?:get|show|print|cat)\s+(?:\S+[ ./-])?(?:tokens?|auth|credentials?|secrets?|api[_-]?keys?|bearer)\b/i, +]; + /** Whether a path or line's tokens name money: a metering or charge word (a cost word too, for a path), or a pair. */ const namesMoney = (t: readonly string[], words: readonly string[]) => t.some(x => words.includes(x)) || MONEY_PAIRS.some(([w, beside]) => t.includes(w) && t.some(x => beside.includes(x))); /** A hash of the patterns that decide the risk classes, not of this file's bytes; lib/review-depth.ts folds it into DEPTH_RULES. */ export const RISK_RULES = createHash('sha256').update(JSON.stringify({ - RISK_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, SECURITY_PATH_TOKENS, DATA_PATH_TOKENS, + RISK_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, SECURITY_PATH_TOKENS, DATA_PATH_TOKENS, EVAL_PATH_TOKENS, MONEY_LINE_TOKENS, MONEY_PAIRS, - patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, DATA_LINE, SVG_PATH: [SVG_PATH], SVG_ACTIVE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]), + patterns: Object.entries({ DOC_SECRET_LINE, CONFIG_DATA_PATH: [CONFIG_DATA_PATH], STATS_SCRIPT: [STATS_SCRIPT], MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, DATA_LINE, SVG_PATH: [SVG_PATH], SVG_ACTIVE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]), })).digest('hex').slice(0, 16); export const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); @@ -93,6 +114,8 @@ export interface RiskInput { removed?: Record; /** gstack-diff-scope's SCOPE_AUTH: the auth class needs it and an auth token in the path. */ scopeAuth: boolean; + /** The base policy's review.risk_paths matcher: a code path it matches is risk class `policy`. */ + isRiskPath?: (p: string) => boolean; } /** The risk classes the code paths touch, the first reason per class, in RISK_CLASSES order. Nothing fails closed here: input errors are the caller's. */ @@ -100,16 +123,21 @@ export function matchRisk(i: RiskInput): Map { const why = new Map(); const hit = (c: RiskClass, reason: string) => { if (!why.has(c)) why.set(c, reason); }; for (const p of i.codePaths) { + if (i.isRiskPath?.(p)) hit('policy', 'risk_paths: ' + p); + if (STATS_SCRIPT.test(p)) continue; const t = tokens(p); + const config = CONFIG_DATA_PATH.test(p); if (i.scopeAuth && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', 'auth path: ' + p); if (SECRET_PATH.test(p)) hit('secret', 'secret path: ' + p); if (namesMoney(t, MONEY_PATH_TOKENS)) hit('money', 'money path: ' + p); if (t.some(x => SECURITY_PATH_TOKENS.includes(x))) hit('security', 'security path: ' + p); if (t.some(x => DATA_PATH_TOKENS.includes(x))) hit('data', 'data path: ' + p); + if (!config && t.some(x => EVAL_PATH_TOKENS.includes(x))) hit('eval', 'eval scorer path: ' + p); if (SVG_PATH.test(p) && [i.added?.[p], i.removed?.[p]].some(l => l && svgActive(l.join('\n')))) hit('security', 'active SVG lines: ' + p); for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) { - if (!why.has('money') && (MONEY_LINE.some(r => r.test(line)) || namesMoney(tokens(line), MONEY_LINE_TOKENS))) hit('money', 'money line: ' + p); if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', 'secret line: ' + p); + if (config) continue; + if (!why.has('money') && (MONEY_LINE.some(r => r.test(line)) || namesMoney(tokens(line), MONEY_LINE_TOKENS))) hit('money', 'money line: ' + p); if (!why.has('security') && SECURITY_LINE.some(r => r.test(line))) hit('security', 'security line: ' + p); if (!why.has('data') && DATA_LINE.some(r => r.test(line))) hit('data', 'data line: ' + p); } diff --git a/lib/ship-policy-review.ts b/lib/ship-policy-review.ts new file mode 100644 index 000000000..d2ed4165a --- /dev/null +++ b/lib/ship-policy-review.ts @@ -0,0 +1,26 @@ +/** + * ship-policy-review — the base-read `review` key of .gstack/ship-policy.json (docs/review-policy.md#review-depth), + * kept beside lib/ship-policy.ts so that module stays under the size ratchet. `panel` keeps today's reviewer panel + * for a class (risk by default: the printed full-depth plan and its full pass 2 on risk class, one reader elsewhere; R65 as amended by FOUN-64), `reader_model` is the model of that one reader, + * `platform` lets a non-risk diff rely on the platform review at its exact head, and `risk_paths` adds code + * paths to R51's risk classes. + */ +export interface ReviewPolicy { panel: 'off' | 'risk' | 'all'; reader_model: string; platform: 'off' | 'on'; risk_paths: string[] } +export const REVIEW_POLICY_DEFAULT: ReviewPolicy = { panel: 'risk', reader_model: 'lane', platform: 'off', risk_paths: [] }; +const MODEL = /^[A-Za-z0-9][\w./:-]{0,127}$/; + +/** The key with every omitted field at its default (a policy file may set any subset). */ +export function reviewPolicy(p: { review?: Partial | null } | null | undefined): ReviewPolicy { + return { ...REVIEW_POLICY_DEFAULT, ...(p?.review ?? {}) }; +} + +/** `review`: each field's error at its path; `globs` checks risk_paths as ship-policy checks every glob list. */ +export function reviewErrors(value: unknown, out: { code: 'POLICY_INVALID'; path: string; message: string }[] | any[], globs: (key: string, v: unknown) => void): void { + if (!value || typeof value !== 'object' || Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'review', message: 'must be { panel?, reader_model?, platform?, risk_paths? }' }); return; } + const v = value as Record; + for (const k of Object.keys(v)) if (!(k in REVIEW_POLICY_DEFAULT)) out.push({ code: 'POLICY_INVALID', path: `review.${k}`, message: 'unknown key' }); + if ('panel' in v && !['off', 'risk', 'all'].includes(v.panel as string)) out.push({ code: 'POLICY_INVALID', path: 'review.panel', message: `must be off | risk | all, got ${JSON.stringify(v.panel)}` }); + if ('platform' in v && !['off', 'on'].includes(v.platform as string)) out.push({ code: 'POLICY_INVALID', path: 'review.platform', message: `must be off | on, got ${JSON.stringify(v.platform)}` }); + if ('reader_model' in v && (typeof v.reader_model !== 'string' || !MODEL.test(v.reader_model))) out.push({ code: 'POLICY_INVALID', path: 'review.reader_model', message: 'must be lane, cross-family or a model id (letters, digits and . _ / : -)' }); + if ('risk_paths' in v) globs('review.risk_paths', v.risk_paths); +} diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index d7dbefd83..11ad2f0b5 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -17,6 +17,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { defaultBaseBranch, noDefaultBaseLine } from './default-base'; import { RESULT_CODES, type ResultCodeName } from './result-codes'; +import { reviewErrors, type ReviewPolicy } from './ship-policy-review'; import { cmpVersion, parseVersion } from './version-source'; export const SHIP_POLICY_PATH = '.gstack/ship-policy.json'; @@ -56,6 +57,7 @@ export interface ShipPolicy { review_scope: ReviewScope; /** Git glob pathspecs (root-anchored) whose data, docs, binary and log files /ship's reviewers leave out; read from the base only (docs/ship-policy.md#review-exclude-paths). */ review_exclude_paths: string[]; + /** How much review /ship runs (docs/review-policy.md#review-depth): the panel fallback, the risk reader's model, platform review and extra risk paths; read from the base only. */ review: ReviewPolicy; /** Consumed by the plan reviews (D1). */ pr_count: { default_pr_count: number; split_requires_owner: boolean }; /** Maintainer logins for contributor-PR mode (E3); empty means "use push permission". */ @@ -157,6 +159,7 @@ export const DEFAULT_POLICY: ShipPolicy = { stamp_sensitive_gates: [], review_scope: 'frozen', review_exclude_paths: [], + review: { panel: 'risk', reader_model: 'lane', platform: 'off', risk_paths: [] }, pr_count: { default_pr_count: 1, split_requires_owner: true }, maintainers: [], stamp_files: [], @@ -189,6 +192,7 @@ export const POLICY_EXPLANATIONS: Record = { stamp_sensitive_gates: 'Evidence labels (gstack-evidence --label) that read VERSION or generated bytes; the tree receipt never marks them reusable.', review_scope: 'frozen | diff-plus-blocking | all. frozen (default): /ship Step 9 fixes findings in the diff on pass 1, files blocking findings in code the PR did not change as issues, and later passes read only the range since the previous pass and fix only blocking findings; diff-plus-blocking: the same, but a blocking finding in code the PR did not change is fixed in this PR instead of filed; all: every finding is fixed now (the pre-1.91.249 loop).', review_exclude_paths: 'Git glob pathspecs, root-anchored and case-sensitive (`receipts/**`, `**/*.jsonl`; at most 64, 256 characters each, no leading `:` or `!`): /ship\'s reviewers leave out the data, docs, binary and .log files they match. Read from the base branch only, so a branch cannot exclude its own files. Code, gate paths, agent prose, config, executables, .gstack/ and .github/ files, gate-tagged or secret-bearing files and files a kept code or test file names by full path stay reviewed (docs/ship-policy.md#review-exclude-paths).', + review: '{ panel?, reader_model?, platform?, risk_paths? }, read from the base branch only. panel: risk (default: today\'s panel at full depth on risk class, one reader on the rest) | off (one merged reader on risk class too) | all (today\'s panel and loop everywhere); every value but all runs one fix round. reader_model: the model of the one reader a lean depth runs, `lane` (default: the lane\'s own model, the native adversarial reader), `cross-family` or a model id. platform: off (default) | on: a docs or tests diff with no numeric, results or prereg claims whose PR carries the platform review (capy-ai[bot]) at the exact head skips /ship\'s readers (review_depth platform); never runtime code. risk_paths: globs of code paths that are risk class on top of R51\'s; data, docs and binary files under them stay out (docs/review-policy.md#review-depth).', pr_count: '{ default_pr_count, split_requires_owner }: the PR-count row in plan reviews.', maintainers: 'GitHub logins treated as maintainers by /review contributor mode; empty uses repository push permission.', stamp_files: 'The repo’s stamp rule: globs of the only files that may differ by a stamp (version file, manifests, plugin trees, the PR’s own since: rows). Empty keeps gstack’s per-file rules; set, it applies before every rule.', @@ -237,6 +241,7 @@ export const BASE_RED_KEY_VERSION = '1.91.374.0'; /** `train.gates` and `train.land` are values of the older train key, so compatErrors checks them by value, as for bump: never. */ export const TRAIN_GATES_KEY_VERSION = '1.92.0.0'; export const REVIEW_EXCLUDE_KEY_VERSION = '1.94.0.0'; +export const REVIEW_POLICY_KEY_VERSION = '1.94.0.0'; export const POLICY_KEY_INTRODUCED: Record = { bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0', changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0', @@ -244,7 +249,7 @@ export const POLICY_KEY_INTRODUCED: Record = { migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION, train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION, changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION, - base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, + base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, review: REVIEW_POLICY_KEY_VERSION, }; const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0; @@ -269,18 +274,12 @@ export function minimumReader(raw: Record): string { export interface PolicyError { code: ResultCodeName; path: string; message: string } -const MAX_LIST = 64; -const MAX_PATTERN = 256; -const MAX_REGEX = 512; -const MAX_COMMAND = 512; +const MAX_LIST = 64, MAX_PATTERN = 256, MAX_REGEX = 512, MAX_COMMAND = 512; const SHA_RE = /^[0-9a-f]{7,64}$/; -const MAX_TEMPLATES = 16; -const MAX_TEMPLATE = 128; +const MAX_TEMPLATES = 16, MAX_TEMPLATE = 128; export const VERSION_SLOT = '{version}'; -function hasDotDot(p: string): boolean { - return p.split(/[\\/]+/).some(seg => seg === '..'); -} +const hasDotDot = (p: string): boolean => p.split(/[\\/]+/).some(seg => seg === '..'); /** A repo-relative path or glob: no absolute paths, drive letters, `..` segments, NULs or control bytes. */ export function containedRelative(p: string): string | null { @@ -488,6 +487,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = oneOf('history', ['merge-only', 'squash-ok']); oneOf('review_scope', ['frozen', 'diff-plus-blocking', 'all']); if ('review_exclude_paths' in obj) excludeErrors(obj.review_exclude_paths, errors); + if ('review' in obj) reviewErrors(obj.review, errors, (k, v) => listErrors(k, v, errors, globError)); if ('release_tool' in obj && obj.release_tool !== null) { const t = obj.release_tool; if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'release_tool', message: 'must be a non-empty string or null' }); diff --git a/test/review-depth-lean.test.ts b/test/review-depth-lean.test.ts new file mode 100644 index 000000000..5065f7632 --- /dev/null +++ b/test/review-depth-lean.test.ts @@ -0,0 +1,193 @@ +import { describe, test, expect } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { classifyDiff, nextStep, planReview, scanNumbers, type PlanInput, type ReviewShape } from '../lib/review-depth'; +import { matchRisk } from '../lib/review-risk'; +import { renderBrief, sectionTexts } from '../lib/review-brief'; +import { validatePolicy, DEFAULT_POLICY, REVIEW_POLICY_KEY_VERSION } from '../lib/ship-policy'; + +const ROOT = path.join(import.meta.dir, '..'); +const none = scanNumbers({}); +const HEAD = 'a'.repeat(40); +const lean = (o: Partial = {}): ReviewShape => ({ panel: 'risk', readerModel: 'lane', platform: 'off', head: HEAD, platformReview: null, ...o }); +const base = (o: Partial): PlanInput => ({ paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 120, history: { fullPasses: 0, last: 'none' }, numbers: none, shape: lean(), ...o }); +const roles = (p: ReturnType) => p.dispatches.map(d => `${d.role}:${d.name}:${d.effort}:${d.model ?? '-'}`); + +describe('HARN-1 false triggers (harness-evals#231 on 1.93.7.0): data, config and stats scripts never raise the depth', () => { + const sqlite = 'docs/benchmarks/2026-11-01-installer-cc-codex/results/cc-none.smoke.ledger.sqlite'; + test('committed sqlite ledgers git cannot count are data, not code, and never name money', () => { + const c = classifyDiff({ paths: [sqlite, 'README.md'], gateTagged: 0, perPath: [{ path: sqlite, lines: 0, binary: true }, { path: 'README.md', lines: 3, binary: false }], consumed: [] }); + expect([c.class, c.errors.map(e => e.code), c.risk]).toEqual(['docs', [], []]); + }); + test('a sqlite file is data even when a script names it', () => { + const c = classifyDiff({ paths: [sqlite, 'scripts/load.py'], gateTagged: 0, perPath: [{ path: sqlite, lines: 0, binary: true }, { path: 'scripts/load.py', lines: 4, binary: false }], consumed: [sqlite] }); + expect([c.class, c.errors.map(e => e.code), c.runtimeLines]).toEqual(['code', [], 4]); + }); + test('money on lease_usd lines of a JSON config a script reads is not a risk class; the config is judged by its path', () => { + const p = 'docs/benchmarks/x/results/cc-none.shard-vm.json'; + expect(matchRisk({ codePaths: [p], added: { [p]: [' "lease_usd": 32.9,', ' "list_price_usd": 1.2485,'] }, scopeAuth: false }).size).toBe(0); + expect(matchRisk({ codePaths: ['config/billing.json'], added: { 'config/billing.json': ['{}'] }, scopeAuth: false }).has('money')).toBe(true); + expect(matchRisk({ codePaths: ['lib/lease.ts'], added: { 'lib/lease.ts': ['const lease_usd = 3;'] }, scopeAuth: false }).has('money')).toBe(true); + }); + test('a stats script under a results directory is judged by its path: no security or money from its name or lines', () => { + const p = 'docs/benchmarks/2026-11-01-installer-cc-codex/results/cost-intervals.py'; + expect(matchRisk({ codePaths: [p], added: { [p]: ["rows = [json.loads(l) for l in open(os.path.join(sys.argv[1], 'trials.jsonl'))]"] }, scopeAuth: false }).size).toBe(0); + expect(matchRisk({ codePaths: ['tools/fetch.py'], added: { 'tools/fetch.py': ['p = os.path.join(sys.argv[1], name)'] }, scopeAuth: false }).has('security')).toBe(true); + }); + test('a secret in a config file still counts', () => { + expect(matchRisk({ codePaths: ['deploy/app.yaml'], added: { 'deploy/app.yaml': [' key: ${OPENAI_API_KEY}'] }, scopeAuth: false }).has('secret')).toBe(true); + }); +}); + +describe('FOUN-64 item 4: docs that can print a credential are security risk (gbrain#8417)', () => { + const docsWith = (line: string) => classifyDiff({ paths: ['docs/mcp.md'], gateTagged: 0, added: { 'docs/mcp.md': [line] } }); + test.each([['$ claude mcp get gbrain'], ['Authorization: Bearer abcdefgh12345678'], ['export OPENAI_API_KEY=sk-live-0123456789abcdefghij'], ['gh auth token']])('%s', l => expect(docsWith(l).risk).toEqual(['security'])); + test('ordinary docs stay risk-free', () => expect(docsWith('Run gstack-review-plan --base main to see the plan.').risk).toEqual([])); +}); + +describe('FOUN-32 §4: eval scorers, judges and graders are risk class eval by path', () => { + test.each([['lib/eval-scorer.ts'], ['evals/judges/rubric.py'], ['scripts/grade-trials.ts']])('%s', p => expect(classifyDiff({ paths: [p], gateTagged: 0 }).risk).toContain('eval')); + test('a test helper named judge stays tests class, and an ordinary path is not eval', () => { + expect(classifyDiff({ paths: ['test/helpers/llm-judge.ts'], gateTagged: 0 }).risk).toEqual([]); + expect(classifyDiff({ paths: ['lib/review-pass.ts'], gateTagged: 0 }).risk).not.toContain('eval'); + }); +}); + +describe('risk_paths: a repo extends the risk classes from its base policy (FOUN-64)', () => { + const rp = ['src/balance/**', 'src/outcome*.ts']; + test('a matching code path is risk class policy; data and docs under the glob are not', () => { + expect(classifyDiff({ paths: ['src/balance/view.tsx'], gateTagged: 0, riskPaths: rp }).risk).toEqual(['policy']); + expect(classifyDiff({ paths: ['src/outcome-score.ts'], gateTagged: 0, riskPaths: rp }).risk).toEqual(['policy']); + expect(classifyDiff({ paths: ['src/balance/snap.sqlite', 'src/balance/notes.md'], gateTagged: 0, riskPaths: rp, consumed: [] }).risk).toEqual([]); + expect(classifyDiff({ paths: ['src/app.ts'], gateTagged: 0, riskPaths: rp }).risk).toEqual([]); + }); + test('a policy risk path plans the merged reader at full depth', () => { + const p = planReview(base({ paths: ['src/balance/x.ts'], riskPaths: rp, shape: lean({ panel: 'off' }) })); + expect([p.depth, roles(p)]).toEqual(['full', ['adversarial-native:merged:full:lane']]); + expect(planReview(base({ paths: ['src/balance/x.ts'], riskPaths: rp })).mode).toBe('panel'); + }); +}); + +describe('R65 lean dispatch (GSTA-258 build C): one reviewer, once', () => { + test('non-risk code: one native reader at the lane\'s reasoning carrying the adversarial brief, the checklists and the Red Team', () => { + const p = planReview(base({})); + expect([p.mode, roles(p)]).toEqual(['lean', ['adversarial-native:single:full:lane']]); + expect(p.dispatches[0]!.checklists).toEqual(['testing', 'security', 'red-team']); + expect(p.skipped.join('\n')).toMatch(/outside-voice/); + }); + test('non-risk code that publishes numbers keeps the native reader by default (FOUN-32 §3, ggstack#52); cross-family is a setting', () => { + const p = planReview(base({ paths: ['src/app.ts', 'docs/results.md'], numbers: scanNumbers({ 'docs/results.md': ['recall 87% (26/30)'] }) })); + expect(roles(p)).toEqual(['adversarial-native:single:full:lane']); + expect(roles(planReview(base({ shape: lean({ readerModel: 'cross-family' }) })))).toEqual(['adversarial-native:single:full:cross-family']); + }); + test('default (panel risk, FOUN-64 item 1): a risk-class diff gets the printed full-depth panel; a fixed blocker re-runs the printed plan as round 2 (FOUN-32 §5)', () => { + const today = roles(planReview(base({ paths: ['lib/billing.ts'], targeted: { classes: ['money'] }, shape: undefined }))); + const p = planReview(base({ paths: ['lib/billing.ts'], targeted: { classes: ['money'] } })); + expect([p.depth, p.mode, roles(p)]).toEqual(['full', 'panel', today]); + expect(today.length).toBeGreaterThan(1); + expect(planReview(base({ paths: ['lib/billing.ts'], history: { fullPasses: 1, last: 'full', fixedBlocking: true } })).next.step).toBe('full'); + expect(planReview(base({ paths: ['lib/billing.ts'], history: { fullPasses: 2, last: 'full', fixedBlocking: true } })).next.step).toBe('stop'); + expect(planReview(base({ history: { fullPasses: 1, last: 'full', fixedBlocking: true } })).next.step).toBe('fix-check'); + }); + test('panel off: risk class gets one reader on the configured model with the merged panel brief; it carries the targeted classes', () => { + const p = planReview(base({ paths: ['lib/billing.ts'], targeted: { classes: ['money'] }, shape: lean({ panel: 'off' }) })); + expect([p.depth, roles(p)]).toEqual(['full', ['adversarial-native:merged:full:lane']]); + expect(p.dispatches[0]!.classes).toEqual(['money']); + expect(p.dispatches[0]!.checklists).toContain('red-team'); + const opus = planReview(base({ paths: ['.github/workflows/ci.yml'], shape: lean({ panel: 'off', readerModel: 'anthropic/claude-opus-5-5' }) })); + expect(roles(opus)).toEqual(['adversarial-native:merged:full:anthropic/claude-opus-5-5']); + }); + test('docs and tests stay core only; docs with numbers keep the outside voice', () => { + expect(planReview(base({ paths: ['README.md'], gateTagged: null })).dispatches).toEqual([]); + expect(roles(planReview(base({ paths: ['docs/r.md'], numbers: scanNumbers({ 'docs/r.md': ['cost $4.10'] }) })))).toEqual(['outside-voice:outside-voice:host:-']); + }); + test('panel all is today\'s panel; panel risk keeps it on risk class only', () => { + const today = (o: Partial) => roles(planReview(base({ ...o, shape: undefined }))); + expect(roles(planReview(base({ paths: ['lib/billing.ts'], shape: lean({ panel: 'all' }) })))).toEqual(today({ paths: ['lib/billing.ts'] })); + expect(roles(planReview(base({ paths: ['lib/billing.ts'], shape: lean({ panel: 'risk' }) })))).toEqual(today({ paths: ['lib/billing.ts'] })); + expect(planReview(base({ shape: lean({ panel: 'risk' }) })).mode).toBe('lean'); + expect(planReview(base({ shape: lean({ panel: 'all' }) })).mode).toBe('panel'); + }); +}); + +describe('C6 platform depth (FOUN-32, founderstar#496): a planned skip needs the platform review at the exact head', () => { + const on = (review: ReviewShape['platformReview']) => lean({ platform: 'on', platformReview: review }); + const docs = { paths: ['docs/howto-install.md', 'test/a.test.ts'] }; + test('platform on with the review at HEAD on a docs or tests diff: no reader, a planned SKIP for every reader, the core read included', () => { + const p = planReview(base({ ...docs, shape: on({ id: '5744388738', head: HEAD, reviewer: 'capy-ai[bot]' }) })); + expect([p.mode, p.next.step, p.dispatches]).toEqual(['platform', 'platform', []]); + expect(p.skipped.filter(s => /planned skip/.test(s)).length).toBeGreaterThanOrEqual(2); + expect(p.skipped.join('\n')).toMatch(/core.*planned skip.*5744388738/); + }); + test('without the evidence, or with it at another head, the core read stands', () => { + expect(planReview(base({ ...docs, shape: on(null) })).mode).toBe('lean'); + expect(planReview(base({ ...docs, shape: on({ id: '1', head: 'b'.repeat(40), reviewer: 'capy-ai[bot]' }) })).next.step).toBe('full'); + }); + test('FOUN-64 item 2: runtime code, results or prereg paths never take the platform depth', () => { + const ev = { id: '1', head: HEAD, reviewer: 'capy-ai[bot]' }; + expect(roles(planReview(base({ shape: on(ev) })))).toEqual(['adversarial-native:single:full:lane']); + expect(planReview(base({ paths: ['docs/benchmarks/run/results/summary.md'], shape: on(ev) })).mode).toBe('lean'); + expect(planReview(base({ paths: ['evals/prereg.md'], shape: on(ev) })).mode).toBe('lean'); + }); + test('risk class and published numbers never take the platform depth', () => { + const ev = { id: '1', head: HEAD, reviewer: 'capy-ai[bot]' }; + expect(planReview(base({ paths: ['lib/billing.ts'], shape: on(ev) })).mode).toBe('panel'); + expect(planReview(base({ paths: ['src/app.ts', 'docs/r.md'], numbers: scanNumbers({ 'docs/r.md': ['p50 4.7 h'] }), shape: on(ev) })).mode).toBe('lean'); + }); + test('the platform skip is pass 1 only: a later step plans as lean', () => { + const p = planReview(base({ shape: on({ id: '1', head: HEAD, reviewer: 'capy-ai[bot]' }), history: { fullPasses: 1, last: 'full', fixedBlocking: true } })); + expect(p.next.step).toBe('fix-check'); + }); +}); + +describe('R65 lean loop: one fix round, never more than 2 rounds', () => { + const h = (o: object) => ({ fullPasses: 1, last: 'full' as const, lean: true, ...o }); + test('non-risk: a fixed blocker earns one fix-check; below-floor fixes ship as notes', () => { + expect(nextStep(h({ fixedBlocking: true }), 'light').step).toBe('fix-check'); + expect(nextStep(h({ fixedBelow: true }), 'light')).toMatchObject({ step: 'done', reason: expect.stringContaining('notes') }); + }); + test('risk: any fix earns one scoped fix-check, a gate-class edit included, never a second full pass', () => { + for (const o of [{ fixedBlocking: true }, { fixedBelow: true }, { fixedBelow: true, touchedGate: true }]) expect(nextStep(h(o), 'full').step).toBe('fix-check'); + }); + test('a fix-check blocker stops for the owner; a recorded grant adds one fix-check, never two', () => { + expect(nextStep(h({ last: 'fix-check', fixedBlocking: true, fixChecks: 1 }), 'full').step).toBe('stop'); + expect(nextStep(h({ last: 'fix-check', fixedBlocking: true, fixChecks: 1, granted: 'apr_1' }), 'full').step).toBe('fix-check'); + expect(nextStep(h({ last: 'fix-check', fixedBlocking: true, fixChecks: 2, granted: 'apr_1' }), 'full').step).toBe('stop'); + }); + test('planReview marks the loop lean and a fix-check dispatches one full-effort reader', () => { + expect(roles(planReview(base({ paths: ['lib/billing.ts'], history: { fullPasses: 1, last: 'full', fixedBlocking: true }, shape: lean({ panel: 'off' }) })))).toEqual(['fix-check:fix-check:full:-']); + }); +}); + +describe('C7 merged reader brief and the outside voice reading its diff from disk', () => { + const t = sectionTexts(ROOT); + const sha = 'c'.repeat(40); + test('the merged reader renders the targeted brief and every checklist in one native adversarial context', () => { + const brief = fs.readFileSync(path.join(ROOT, 'review/specialists/targeted.md'), 'utf8'); + const text = renderBrief('adversarial-native', { diff_base: sha, checklists: '/x/testing.md,/x/red-team.md', classes: 'money', brief, checklist: '/x/targeted.md' }, t); + expect(text).toContain(t.native.slice(0, 60)); + expect(text).toContain('/x/testing.md, /x/red-team.md'); + expect(text).toMatch(/money/); + }); + test('the outside voice gets a path and a hash, not the pasted diff', () => { + const text = renderBrief('outside-voice', { diff_base: sha, diff_file: '/tmp/o/outside-voice.diff', diff_sha256: 'd'.repeat(64), diff_bytes: '1234' }, t); + expect(text).toContain('/tmp/o/outside-voice.diff'); + expect(text).toContain('d'.repeat(64)); + expect(text).not.toContain('```diff'); + }); +}); + +describe('ship policy: the base-read review key', () => { + const codes = (raw: object) => validatePolicy(raw).map(e => `${e.code}:${e.path}`); + test('defaults: panel risk, the lane\'s native reader, platform off, no extra risk paths', () => expect(DEFAULT_POLICY.review).toEqual({ panel: 'risk', reader_model: 'lane', platform: 'off', risk_paths: [] })); + test('valid values pass with min_gstack_version; bad ones name the field', () => { + const v = REVIEW_POLICY_KEY_VERSION; + expect(codes({ review: { platform: 'on', reader_model: 'anthropic/claude-opus-5-5', panel: 'risk', risk_paths: ['src/balance/**'] }, min_gstack_version: v })).toEqual([]); + expect(codes({ review: { platform: 'yes' }, min_gstack_version: v })).toEqual(['POLICY_INVALID:review.platform']); + expect(codes({ review: { panel: 'some' }, min_gstack_version: v })).toEqual(['POLICY_INVALID:review.panel']); + expect(codes({ review: { reader_model: 'a b' }, min_gstack_version: v })).toEqual(['POLICY_INVALID:review.reader_model']); + expect(codes({ review: { risk_paths: ['../x'] }, min_gstack_version: v })).toEqual(['POLICY_CONTAINMENT:review.risk_paths[0]']); + expect(codes({ review: { other: 1 }, min_gstack_version: v })).toEqual(['POLICY_INVALID:review.other']); + expect(codes({ review: { platform: 'on' } })).toContain('POLICY_INVALID:review'); + }); +}); diff --git a/test/review-targeted.test.ts b/test/review-targeted.test.ts index 0f53a551a..6923812ab 100644 --- a/test/review-targeted.test.ts +++ b/test/review-targeted.test.ts @@ -189,8 +189,8 @@ function planRepo(f: Fixture) { */ const FULL_PANEL = ['specialist', 'red-team', 'adversarial-native', 'outside-voice']; const R51: Record = { - 'none-light': { depth: 'light', panel: false }, 'none-standard': { depth: 'light', panel: false, dropped: ['outside-voice'] }, 'none-docs': { depth: 'light', panel: false }, - 'none-tests': { depth: 'light', panel: false }, 'none-costume': { depth: 'light', panel: false }, 'none-gate-path': { depth: 'full', panel: true }, + 'none-light': { depth: 'light', panel: false, dropped: ['specialist'] }, 'none-standard': { depth: 'light', panel: false, dropped: ['specialist', 'outside-voice'] }, 'none-docs': { depth: 'light', panel: false }, + 'none-tests': { depth: 'light', panel: false }, 'none-costume': { depth: 'light', panel: false, dropped: ['specialist'] }, 'none-gate-path': { depth: 'full', panel: true }, 'secret-light': { depth: 'full', panel: true }, 'secret-path': { depth: 'full', panel: true }, 'secret-removed': { depth: 'full', panel: true }, 'gate-tagged': { depth: 'full', panel: true }, 'money-light': { depth: 'full', panel: true }, 'money-path': { depth: 'full', panel: true }, 'money-removed': { depth: 'full', panel: true }, 'money-standard': { depth: 'full', panel: true }, 'auth-path': { depth: 'full', panel: true }, 'money-fix-check': { depth: 'full', panel: false }, 'money-pass2': { depth: 'full', panel: false }, 'gate-repeat': { depth: 'full', panel: false }, @@ -204,7 +204,8 @@ describe('gstack-review-plan prints TARGETED and dispatches the targeted reader' // with no class; seam=bin/gstack-review-plan end to end; no_claim=that the reader finds the defect (the paid replay's question) // R51 changes the table: risk and gate fixtures plan full (every role main dispatched, plus the full panel), and the one // non-risky standard fixture drops only the outside voice; fails_when=a risk fixture loses a role main dispatched or misses - // the full panel, or a non-risky fixture loses a role the R51 table does not list + // the full panel, or a non-risky fixture loses a role the R51 table does not list. R65 (GSTA-258 build C): a non-risky code + // fixture runs one native adversarial reader carrying the checklists and the Red Team, so its batched specialist drops too test('the dispatch-superset no-loss check: risk and gate fixtures keep every role main dispatched plus the full panel; non-risky ones drop only what R51 lists', () => { expect(Object.keys(MAIN).sort()).toEqual(Object.keys(FIXTURES).sort()); expect(Object.keys(R51).sort()).toEqual(Object.keys(FIXTURES).sort());