config-audit/scanners/drift-cli.mjs
Kjell Tore Guttormsen caea8aca23 fix(commands): stop answering questions the caller did not ask
Dogfooding `campaign` + `knowledge-refresh` against a throwaway ledger. Seven
defects, all found by running the commands as written and measuring, not by
reading them.

The headline pair only existed together. `knowledge-refresh` built
`STALE_AFTER="--stale-after 30"` and expanded it unquoted, trusting the shell to
split it in two. bash does; zsh — the macOS default, and what the Bash tool runs
here — does not. The CLI got one argv entry, matched no flag, and because it had
no unknown-flag branch, silently kept the 90-day default and reported "✓ All 14
register entries were re-verified within the last 90 days": a true-sounding
sentence about a threshold the user had just overridden. Fixing either half alone
leaves a silent wrong answer or a loud one; both are fixed, and a guard now
rejects any template that packs a flag and its value into one variable.

`knowledge-refresh` also read one register and wrote another: step 6 named an
unanchored `knowledge/best-practices.json` while the CLI reads
`${CLAUDE_PLUGIN_ROOT}/…`, which for an installed plugin is the cache. The
validation gate then ran the cached test against the cached register — green no
matter what was written. The two copies were byte-identical that day, which is
exactly why it was invisible.

`campaign` vouched for repos it could not read. `add /finnes/ikke` returned
`added` + exit 0; `refresh-tokens` then put the phantom in `swept[]` with a
0-token delta and left `skipped[]` empty, so the machine-wide bill claimed
coverage of three repos on a machine with two. Paths stay tracked — an unmounted
volume is a legitimate absence — but are reported as `addedUnverified`, and the
command names them.

Two class sweeps, both measured rather than assumed. `posture` was the single
scanner (1 of 14) whose fatal catch exited 1, which ux-rules defines as a normal
WARNING grade — a crash indistinguishable from a result. And all 13 payload
writers failed on a `--output-file` whose parent did not exist, which on a fresh
machine turned `campaign`'s first run into "the ledger may be corrupt"; they now
share `scanners/lib/write-output.mjs`.

Predicted breadth was too wide for the first time in five sessions: 6 of 8 CLIs
predicted to lack unknown-flag rejection, 4 measured. `drift` and `fix` already
reject them, via a construct the grep did not recognise — a grep matches an
implementation, the invariant is a behaviour. The sweep was rewritten to run each
CLI with a bogus flag and read the exit code.

Suite 1453 → 1469/0. Frozen snapshots untouched. `optimize-lens-cli` and
`token-hotspots-cli` share the unknown-flag defect and are deferred to the v5.14
argument-handling chunk with their positional-swallow arm; the count is recorded
in the guard rather than rounded down to zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NHWjN8EnoxSqRvMTLK2NE
2026-08-01 21:26:39 +02:00

208 lines
8.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Config-Audit Drift CLI
* Compare current configuration against a saved baseline.
* Usage:
* node drift-cli.mjs <path> --save [--name my-baseline]
* node drift-cli.mjs <path> [--baseline my-baseline] [--json] [--output-file path]
* node drift-cli.mjs --list
* Unknown options and value-less --name/--baseline/--output-file exit 3.
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { runAllScanners } from './scan-orchestrator.mjs';
import { diffEnvelopes, formatDiffReport } from './lib/diff-engine.mjs';
import { saveBaseline, loadBaseline, listBaselines } from './lib/baseline.mjs';
import { humanizeFindings } from './lib/humanizer.mjs';
const BOOL_FLAGS = ['--save', '--list', '--json', '--raw', '--global'];
const VALUE_FLAGS = ['--name', '--baseline', '--output-file'];
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let baselineName = 'default';
let save = false;
let list = false;
let jsonMode = false;
let rawMode = false;
let includeGlobal = false;
let outputFile = null;
// M-BUG-21: this loop used to end in `else if (!arg.startsWith('-')) targetPath = arg`,
// with no unknown-flag branch. An unrecognised flag was dropped silently and its
// VALUE fell through to targetPath — so `--output-file /tmp/x.json` scanned
// /tmp/x.json, a path that does not exist, yielding a near-empty scan and
// therefore permanent phantom drift. A missing value for --name was equally
// silent and destructive: it left baselineName at 'default' and OVERWROTE the
// default baseline. Both now fail loudly (exit 3) instead.
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (BOOL_FLAGS.includes(arg)) {
if (arg === '--save') save = true;
else if (arg === '--list') list = true;
else if (arg === '--json') jsonMode = true;
else if (arg === '--raw') rawMode = true;
else if (arg === '--global') includeGlobal = true;
} else if (VALUE_FLAGS.includes(arg)) {
const value = args[i + 1];
if (value === undefined || value.startsWith('-')) {
throw new Error(`Option ${arg} requires a value.`);
}
if (arg === '--name' || arg === '--baseline') baselineName = value;
else outputFile = value;
i++;
} else if (arg.startsWith('-')) {
throw new Error(
`Unknown option: ${arg}\n` +
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
);
} else {
targetPath = arg;
}
}
// --- List mode ---
if (list) {
const result = await listBaselines();
// commands/drift.md runs this with `2>/dev/null` (ux-rules rule 2). The
// human listing below goes to stderr, so without --output-file the command
// received 0 bytes and could render nothing. The flag was already accepted
// by the arg parser; only list mode ignored it.
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(result, null, 2) + '\n', 'utf-8');
if (jsonMode || rawMode) {
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else {
if (result.baselines.length === 0) {
process.stderr.write('No baselines saved.\n');
process.stderr.write('Save one with: node drift-cli.mjs <path> --save\n');
} else {
process.stderr.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
process.stderr.write(' Saved Baselines\n');
process.stderr.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n');
for (const b of result.baselines) {
process.stderr.write(` ${b.name.padEnd(20)} ${b.findingCount} findings ${b.savedAt}\n`);
}
process.stderr.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
}
}
return;
}
// --- Save mode ---
if (save) {
if (!jsonMode && !rawMode) {
process.stderr.write(`Config-Audit Drift CLI v2.1.0\n`);
process.stderr.write(`Saving baseline "${baselineName}" for ${resolve(targetPath)}\n\n`);
}
const envelope = await runAllScanners(targetPath, { includeGlobal, humanizedProgress: !jsonMode && !rawMode });
const result = await saveBaseline(envelope, baselineName);
if (jsonMode || rawMode) {
process.stdout.write(JSON.stringify({ saved: true, name: result.name, path: result.path }, null, 2) + '\n');
} else {
process.stderr.write(`\nBaseline "${result.name}" saved to ${result.path}\n`);
process.stderr.write(`Findings: ${envelope.aggregate.total_findings}\n`);
}
return;
}
// --- Drift mode (default) ---
if (!jsonMode && !rawMode) {
process.stderr.write(`Config-Audit Drift CLI v2.1.0\n`);
process.stderr.write(`Target: ${resolve(targetPath)}\n`);
process.stderr.write(`Baseline: ${baselineName}\n\n`);
}
// Load baseline
const baseline = await loadBaseline(baselineName);
if (!baseline) {
if (jsonMode || rawMode) {
process.stdout.write(JSON.stringify({ error: `Baseline "${baselineName}" not found. Save one with --save.` }, null, 2) + '\n');
} else {
process.stderr.write(`Baseline "${baselineName}" not found.\n`);
process.stderr.write(`Save one first: node drift-cli.mjs <path> --save\n`);
}
process.exitCode = 1;
return;
}
// M-BUG-27: a baseline carries the path it was saved from, but nothing ever
// compared it against the current target. Diffing a repo against a baseline
// anchored elsewhere produced 100% phantom drift — every baseline finding
// "resolved", every current finding "new" — and reported it as trend
// "improving": a reassuring and entirely false signal, on the DEFAULT
// baseline. The warning goes to stderr in every mode; stdout stays
// byte-identical to the frozen v5.0.0 shape.
const baselineTarget = baseline._baseline?.target_path || '';
const currentTarget = resolve(targetPath);
const anchorMatches = !baselineTarget || baselineTarget === currentTarget;
if (baselineTarget && baselineTarget !== currentTarget) {
process.stderr.write(
`\nWarning: baseline "${baselineName}" was saved from a different target path.\n` +
` baseline: ${baselineTarget}\n` +
` current: ${currentTarget}\n` +
` The two scans cover different trees, so this diff is not a drift signal.\n` +
` Re-anchor with: drift-cli.mjs ${currentTarget} --save --name ${baselineName}\n\n`
);
}
// Run current scan
const current = await runAllScanners(targetPath, {
includeGlobal,
humanizedProgress: !jsonMode && !rawMode,
});
// Diff
const diff = diffEnvelopes(baseline, current);
// Default mode: humanize finding-bearing diff fields before report rendering.
// `_baselineAnchor` rides here and NOT in the raw shape: commands/drift.md runs
// the CLI under `2>/dev/null`, so the stderr warning above never reaches the
// caller that has to act on it. --json/--raw stay v5.0.0-shaped.
const humanizedDiff = {
...diff,
_baselineAnchor: { matches: anchorMatches, baselineTarget, currentTarget },
newFindings: humanizeFindings(diff.newFindings || []),
resolvedFindings: humanizeFindings(diff.resolvedFindings || []),
unchangedFindings: humanizeFindings(diff.unchangedFindings || []),
movedFindings: humanizeFindings(diff.movedFindings || []),
};
if (jsonMode || rawMode) {
// --json and --raw both write the raw v5.0.0-shape diff (byte-identical).
process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
} else {
const report = formatDiffReport(humanizedDiff);
process.stderr.write('\n' + report + '\n');
}
// ux-rules rule 2: every scanner Bash call uses `--output-file <path>` and the
// command reads the file with the Read tool. drift-cli had no such flag, and
// its default-mode report goes to stderr — which commands/drift.md discarded
// via `2>/dev/null` while instructing the agent to "read stdout". The command
// captured nothing. Matches posture.mjs: raw diff in --json/--raw, humanized
// otherwise; stdout is unaffected.
if (outputFile) {
const fileDiff = (jsonMode || rawMode) ? diff : humanizedDiff;
await writeOutputFile(outputFile, JSON.stringify(fileDiff, null, 2), 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
}
// Exit code: 0=stable/improving, 1=degrading
process.exitCode = diff.summary.trend === 'degrading' ? 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;
});
}