config-audit/scanners/scan-orchestrator.mjs
Kjell Tore Guttormsen 749b710de7 feat(scanners): the write gate now runs in code, not in the templates' prose
`write-scope.mjs` has existed since M-BUG-41, but only one writer ever called
it. Measured 2026-08-12: 9 files under `scanners/` write to disk, 1 imported
the gate; 21 command templates, 17 mention a write, 5 call `write-scope-cli`.
Five templates paraphrasing one policy is the shape that put the lever table in
five copies (#61) — one level up.

The defect was never "8 ungated writers = 8 bugs". Four of them write the
plugin's own bookkeeping and must STAY ungated: a gate that fires on every run
gets switched off, and then it guards nothing. The defect is that nothing
declared WHICH, so the question was answered by reading, and answered
differently each time it was asked.

`tests/lib/write-gate-coverage.test.mjs` makes the answer structural: every
writer either imports the gate or holds an EXEMPT entry naming where the bytes
land. Seen RED against today's tree before the fix (4 ungated writers), and
each of its four assertions was separately seen red against its own defect.

Two premises in the plan text were falsified by measuring them first:

  - `scan-orchestrator` was carried as "plugin-managed, legitimately exempt".
    `--save-baseline` derives its path from the SCAN TARGET, so `--global`
    lands `~/.claude/.config-audit-baseline.json` — user-scope, require-ok. It
    is gated. `lib/baseline.mjs` is the genuinely exempt one.
  - the first sweep scored 9 writers with a regex that could not match
    `writeFileSync(`, so `lib/backup.mjs` — a real writer — read as clean. The
    guard covers sync and async forms, strips comments before matching, and
    asserts non-emptiness so a regex that stops matching cannot make every
    other assertion vacuously green (#63, #64).

Gated: fix-engine, rollback-engine, campaign-export-cli, scan-orchestrator.
All five call sites share ONE reduction, `evaluateWriteTargets` — four copies
of classify/strongestGate/dedup is the drift this exists to prevent.

`campaign export` still DISCLOSES rather than refuses: cross-repo is by design
there, and tightening it into a refusal would break the feature. A dry run is
still not a write, so it is never gated (#63). A refusal is a verdict about a
config that WAS examined, so it rides in the payload and keeps the 0/1/2 exit
contract (#62) — and the verdict now reaches the success payload too, since
stderr is discarded by `2>/dev/null` (F3's class).

commands/fix.md carries `--approve-scope` from the answer the user gives, with
the rule stated where it can be read: classifying is not approving.

Dogfooded end to end: a target outside the session root refuses with zero bytes
written, then applies under `--approve-scope`.

Suite 1703 -> 1707/0. Frozen v5.0.0 + default-output snapshots: 0 changed files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pkn22uGCgk6QZA738zNmHL
2026-08-12 21:15:16 +02:00

366 lines
15 KiB
JavaScript

#!/usr/bin/env node
/**
* Config-Audit Scan Orchestrator
* Runs all registered scanners sequentially, collects findings, outputs JSON envelope.
* Usage: node scan-orchestrator.mjs <target-path> [--output-file path] [--save-baseline] [--baseline path]
* Zero external dependencies.
*/
import { resolve, sep } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { evaluateWriteTargets } from './lib/write-scope.mjs';
import { requireTargetDir } from './lib/require-target-dir.mjs';
import { envelope } from './lib/output.mjs';
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
import { loadSuppressions, applySuppressions, formatSuppressionSummary, unknownSuppressions } from './lib/suppression.mjs';
import { humanizeEnvelope } from './lib/humanizer.mjs';
import { resolveContextWindow } from './lib/context-window.mjs';
import { resolveActiveModel } from './lib/active-model.mjs';
// Scanner registry — import order determines execution order
import { scan as scanClaudeMd } from './claude-md-linter.mjs';
import { scan as scanSettings } from './settings-validator.mjs';
import { scan as scanHooks } from './hook-validator.mjs';
import { scan as scanRules } from './rules-validator.mjs';
import { scan as scanMcp } from './mcp-config-validator.mjs';
import { scan as scanImports } from './import-resolver.mjs';
import { scan as scanConflicts } from './conflict-detector.mjs';
import { scan as scanGap } from './feature-gap-scanner.mjs';
import { scan as scanTokenHotspots } from './token-hotspots.mjs';
import { scan as scanCachePrefix } from './cache-prefix-scanner.mjs';
import { scan as scanDisabledInSchema } from './disabled-in-schema-scanner.mjs';
import { scan as scanCollision } from './collision-scanner.mjs';
import { scan as scanSkillListing } from './skill-listing-scanner.mjs';
import { scan as scanAgentListing } from './agent-listing-scanner.mjs';
import { scan as scanOutputStyle } from './output-style-scanner.mjs';
import { scan as scanOptimizationLens } from './optimization-lens-scanner.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
const ARG_SPEC = {
boolean: [
'--json', '--raw', '--global', '--full-machine', '--no-suppress',
'--include-fixtures', '--exclude-cache', '--no-exclude-cache', '--save-baseline',
'--approve-scope',
],
value: ['--output-file', '--context-window', '--baseline'],
};
// Directory names that identify test fixture / example directories
const FIXTURE_DIR_NAMES = ['tests', 'examples', '__tests__', 'test-fixtures'];
/**
* Check if a finding originates from a test fixture or example directory
* relative to the scan target. Only filters when the finding's path extends
* beyond the target into a fixture subdirectory — if the target itself is
* a fixture directory, findings are NOT filtered.
* @param {object} f - Finding object
* @param {string} targetPath - Resolved scan target path
* @returns {boolean}
*/
function isFixturePath(f, targetPath) {
const p = f.file || f.path || f.location || '';
if (!p || !p.startsWith(targetPath)) return false;
// Get the path relative to target, then check if it passes through a fixture dir
const rel = p.slice(targetPath.length);
return FIXTURE_DIR_NAMES.some(dir => rel.includes(sep + dir + sep));
}
const SCANNERS = [
{ name: 'CML', fn: scanClaudeMd, label: 'CLAUDE.md Linter' },
{ name: 'SET', fn: scanSettings, label: 'Settings Validator' },
{ name: 'HKV', fn: scanHooks, label: 'Hook Validator' },
{ name: 'RUL', fn: scanRules, label: 'Rules Validator' },
{ name: 'MCP', fn: scanMcp, label: 'MCP Config Validator' },
{ name: 'IMP', fn: scanImports, label: 'Import Resolver' },
{ name: 'CNF', fn: scanConflicts, label: 'Conflict Detector' },
{ name: 'GAP', fn: scanGap, label: 'Feature Gap Scanner' },
{ name: 'TOK', fn: scanTokenHotspots, label: 'Token Hotspots' },
{ name: 'CPS', fn: scanCachePrefix, label: 'Cache-Prefix Stability' },
{ name: 'DIS', fn: scanDisabledInSchema, label: 'Disabled-In-Schema' },
{ name: 'COL', fn: scanCollision, label: 'Plugin Skill Collision' },
{ name: 'SKL', fn: scanSkillListing, label: 'Skill-Listing Budget' },
{ name: 'AGT', fn: scanAgentListing, label: 'Agent-Listing Budget' },
{ name: 'OST', fn: scanOutputStyle, label: 'Output-Style Validation' },
{ name: 'OPT', fn: scanOptimizationLens, label: 'Optimization Lens' },
];
/**
* Run all scanners against target path.
* @param {string} targetPath
* @param {object} [opts]
* @param {boolean} [opts.includeGlobal=false]
* @param {boolean} [opts.fullMachine=false] - Scan all known locations across the machine
* @param {boolean} [opts.suppress=true] - Apply suppressions from .config-audit-ignore
* @param {boolean} [opts.filterFixtures=true] - Exclude findings from test/example paths
* @param {boolean} [opts.excludeCache=true] - Drop stale ~/.claude/plugins/cache versions so findings reflect live config (B3)
* @returns {Promise<object>} Full envelope with all results
*/
// Exported for testing
export { isFixturePath, FIXTURE_DIR_NAMES };
export async function runAllScanners(targetPath, opts = {}) {
const start = Date.now();
const resolvedPath = resolve(targetPath);
// Default ON: stale cached plugin versions otherwise inflate token hotspots
// and CNF duplicate-hook findings with config that loads on zero turns. (B3)
const excludeCache = opts.excludeCache !== false;
// B8 — resolve the context window once and thread it to budget-aware scanners
// (SKL, CML). Undefined opts.contextWindow → conservative 200k anchor, which is
// byte-identical to the pre-B8 default; other scanners ignore the third arg.
// B8b — `--context-window auto` probes the configured model (settings cascade /
// ANTHROPIC_MODEL) so a 1M-tier host self-calibrates; unknown/unpinned → advisory.
let probedModel = null;
if (String(opts.contextWindow ?? '').trim().toLowerCase() === 'auto') {
probedModel = await resolveActiveModel(resolvedPath, { env: process.env });
}
const contextWindow = resolveContextWindow(opts.contextWindow, { model: probedModel });
// Shared file discovery — scanners reuse this
let discovery;
if (opts.fullMachine) {
const roots = await discoverFullMachinePaths();
discovery = await discoverConfigFilesMulti(roots, { excludeCache });
} else {
discovery = await discoverConfigFiles(resolvedPath, {
includeGlobal: opts.includeGlobal || false,
excludeCache,
});
}
const results = [];
for (const scanner of SCANNERS) {
const scanStart = Date.now();
try {
const result = await scanner.fn(resolvedPath, discovery, { contextWindow });
results.push(result);
const count = result.findings.length;
const label = opts.humanizedProgress
? `\`[${scanner.name}] ${scanner.label}\``
: `[${scanner.name}] ${scanner.label}`;
process.stderr.write(` ${label}: ${count} finding(s) (${Date.now() - scanStart}ms)\n`);
} catch (err) {
results.push({
scanner: scanner.name,
status: 'error',
files_scanned: 0,
duration_ms: Date.now() - scanStart,
findings: [],
counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
error: err.message,
});
const label = opts.humanizedProgress
? `\`[${scanner.name}] ${scanner.label}\``
: `[${scanner.name}] ${scanner.label}`;
process.stderr.write(` ${label}: ERROR — ${err.message}\n`);
}
}
// Filter findings from test fixtures / examples (unless disabled)
const shouldFilterFixtures = opts.filterFixtures !== false;
let fixtureFindings = [];
if (shouldFilterFixtures) {
for (const result of results) {
const active = [];
const fixture = [];
for (const f of result.findings) {
if (isFixturePath(f, resolvedPath)) {
fixture.push(f);
} else {
active.push(f);
}
}
if (fixture.length > 0) {
fixtureFindings.push(...fixture);
result.findings = active;
result.counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const f of active) {
if (result.counts[f.severity] !== undefined) result.counts[f.severity]++;
}
}
}
if (fixtureFindings.length > 0) {
process.stderr.write(` ${fixtureFindings.length} finding(s) from test fixtures excluded\n`);
}
}
// Apply suppressions (unless disabled)
const shouldSuppress = opts.suppress !== false;
let suppressedFindings = [];
let deadSuppressions = [];
if (shouldSuppress) {
const { suppressions } = await loadSuppressions(resolvedPath);
// A pin that names no declared check can never match. Report it: a silently
// dead suppression leaves the user believing a finding is hidden when it is
// not (M-BUG-28).
deadSuppressions = unknownSuppressions(suppressions);
if (suppressions.length > 0) {
for (const result of results) {
const { active, suppressed } = applySuppressions(result.findings, suppressions);
suppressedFindings.push(...suppressed);
result.findings = active;
// Recalculate counts
result.counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const f of active) {
if (result.counts[f.severity] !== undefined) result.counts[f.severity]++;
}
}
if (suppressedFindings.length > 0) {
process.stderr.write(` ${formatSuppressionSummary(suppressedFindings)}\n`);
}
}
}
const totalMs = Date.now() - start;
const env = envelope(resolvedPath, results, totalMs);
if (fixtureFindings.length > 0) {
env.fixture_findings = fixtureFindings;
}
if (suppressedFindings.length > 0) {
env.suppressed_findings = suppressedFindings;
}
// ux-rules rule 2: commands run scanners with `2>/dev/null`, so anything they
// must ACT on rides in the payload, never in a stderr-only warning. Added only
// when a dead pin exists, so a config without one is byte-identical.
if (deadSuppressions.length > 0) {
env.unknown_suppressions = deadSuppressions;
}
return env;
}
// --- CLI entry point ---
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
let targetPath = '.';
let outputFile = null;
let saveBaseline = false;
let approveScope = false;
let baselinePath = null;
let contextWindow = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--output-file' && args[i + 1]) {
outputFile = args[++i];
} else if (args[i] === '--context-window' && args[i + 1]) {
contextWindow = args[++i];
} else if (args[i] === '--approve-scope') {
approveScope = true;
} else if (args[i] === '--save-baseline') {
saveBaseline = true;
} else if (args[i] === '--baseline' && args[i + 1]) {
baselinePath = args[++i];
} else if (args[i] === '--global') {
// handled below
} else if (args[i] === '--full-machine') {
// handled below
} else if (args[i] === '--no-suppress') {
// handled below
} else if (args[i] === '--include-fixtures') {
// handled below
} else if (args[i] === '--exclude-cache' || args[i] === '--no-exclude-cache') {
// handled below
} else if (args[i] === '--json') {
// handled below — explicit machine-readable mode (bypass humanizer)
} else if (args[i] === '--raw') {
// handled below — v5.0.0 verbatim mode (bypass humanizer)
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
}
}
const includeGlobal = args.includes('--global');
const fullMachine = args.includes('--full-machine');
const suppress = !args.includes('--no-suppress');
const filterFixtures = !args.includes('--include-fixtures');
const excludeCache = !args.includes('--no-exclude-cache');
const jsonMode = args.includes('--json');
const rawMode = args.includes('--raw');
if (!(await requireTargetDir(resolve(targetPath)))) {
process.exitCode = 3;
return;
}
const humanizedProgress = !jsonMode && !rawMode;
process.stderr.write(humanizedProgress ? `Config-Audit v2.2.0\n` : `Config-Audit Scanner v2.2.0\n`);
process.stderr.write(`Target: ${resolve(targetPath)}\n`);
process.stderr.write(`Scope: ${fullMachine ? 'full-machine' : includeGlobal ? 'global' : 'project'}\n`);
process.stderr.write(`Fixtures: ${filterFixtures ? 'excluded' : 'included'}\n\n`);
const result = await runAllScanners(targetPath, {
includeGlobal,
fullMachine,
suppress,
filterFixtures,
excludeCache,
humanizedProgress,
contextWindow,
});
// Default mode runs the humanizer; --json and --raw bypass for v5.0.0 byte-equal output.
const output = (jsonMode || rawMode) ? result : humanizeEnvelope(result);
const json = JSON.stringify(output, null, 2);
if (outputFile) {
await writeOutputFile(outputFile, json, 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
} else {
process.stdout.write(json + '\n');
}
if (saveBaseline) {
const bPath = baselinePath || resolve(targetPath, '.config-audit-baseline.json');
// Q1 — the scope gate, in code. This write was carried in the plan text as
// one of the plugin's own artifacts, legitimately exempt — measured false: the
// default path is derived from the SCAN TARGET, not from a plugin root, so
// `--global --save-baseline` lands `~/.claude/.config-audit-baseline.json`,
// which is `user-scope` / `require-ok`. `lib/baseline.mjs` is the genuinely
// exempt one — it writes only under `~/.config-audit/baselines`.
const scope = evaluateWriteTargets([bPath], process.cwd());
if (scope.requiresApproval && !approveScope) {
for (const line of scope.disclosures) process.stderr.write(`\n${line}\n`);
process.stderr.write(
`Baseline NOT saved to ${bPath} — re-run with --approve-scope to write it.\n`,
);
} else {
// Always save baselines as raw v5.0.0-shape envelope so future humanizer
// changes don't trigger false-positive drift findings.
await writeFile(bPath, JSON.stringify(result, null, 2), 'utf-8');
process.stderr.write(`Baseline saved to ${bPath}\n`);
}
}
// Summary
const agg = result.aggregate;
process.stderr.write(`\n--- Summary ---\n`);
process.stderr.write(`Findings: ${agg.total_findings} (C:${agg.counts.critical} H:${agg.counts.high} M:${agg.counts.medium} L:${agg.counts.low} I:${agg.counts.info})\n`);
process.stderr.write(`Risk: ${agg.risk_score}/100 (${agg.risk_band})\n`);
process.stderr.write(`Verdict: ${agg.verdict}\n`);
// Exit code. Set, never process.exit(): stdout is written ASYNCHRONOUSLY when
// it is a pipe, and process.exit() discards whatever is still buffered. Piping
// this envelope used to yield truncated, unparseable JSON (246 854 bytes to a
// file vs 65 536 to a pipe) — a corruption that looks like a bad file, not a
// cut-off. Letting Node exit naturally drains stdout first.
process.exitCode = agg.verdict === 'FAIL' ? 2 : agg.verdict === 'WARNING' ? 1 : 0;
}
// Only run CLI if invoked directly
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exitCode = 3;
});
}