`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
310 lines
12 KiB
JavaScript
310 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Config-Audit Fix CLI
|
|
* Standalone entry point for running fixes without the command.
|
|
* Usage: node fix-cli.mjs <path> [--apply] [--global] [--json]
|
|
* Dry-run by default — must pass --apply to write changes.
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { resolve } from 'node:path';
|
|
import { writeOutputFile } from './lib/write-output.mjs';
|
|
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
|
import { runAllScanners } from './scan-orchestrator.mjs';
|
|
import { planFixes, applyFixes, verifyFixes } from './fix-engine.mjs';
|
|
import { createBackup } from './lib/backup.mjs';
|
|
import { humanizeFinding } from './lib/humanizer.mjs';
|
|
|
|
// `--dry-run` is a no-op alias: dry-run is already the default. It exists because
|
|
// commands/fix.md documents it in argument-hint, and a documented flag that the
|
|
// CLI silently drops is the same fail-silent class as the unknown-flag sink below.
|
|
const BOOL_FLAGS = ['--apply', '--dry-run', '--json', '--raw', '--global', '--approve-scope'];
|
|
const VALUE_FLAGS = ['--output-file', '--repo'];
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
let targetPath = '.';
|
|
let apply = false;
|
|
let jsonMode = false;
|
|
let rawMode = false;
|
|
let includeGlobal = false;
|
|
let outputFile = null;
|
|
let approveScope = false;
|
|
// The session's root, never the scan target (#63). `--global` fixes files
|
|
// under `~/.claude` while the session still stands somewhere else, so reading
|
|
// the root off the target would classify a machine-wide write as "in-repo"
|
|
// and silence the strongest gate exactly where it matters.
|
|
let repoRoot = process.cwd();
|
|
|
|
// Same defect class as M-BUG-21 in drift-cli: this loop used to end in
|
|
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
|
|
// unknown-flag branch, so an unrecognised flag was dropped silently and its
|
|
// VALUE became the scan target. Here that is worse than in drift: combined
|
|
// with --apply it silently moves the WRITE target to another tree.
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
|
|
if (BOOL_FLAGS.includes(arg)) {
|
|
if (arg === '--apply') apply = true;
|
|
else if (arg === '--json') jsonMode = true;
|
|
else if (arg === '--raw') rawMode = true;
|
|
else if (arg === '--global') includeGlobal = true;
|
|
else if (arg === '--approve-scope') approveScope = true;
|
|
// --dry-run: default behaviour, accepted so it is not silently dropped.
|
|
} 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 === '--repo') repoRoot = 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;
|
|
}
|
|
}
|
|
|
|
// Whether to suppress prose stderr (true for both --json and --raw machine paths).
|
|
const machineMode = jsonMode || rawMode;
|
|
|
|
const resolvedPath = resolve(targetPath);
|
|
|
|
if (!(await requireTargetDir(resolvedPath))) {
|
|
process.exitCode = 3;
|
|
return;
|
|
}
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(`Config-Audit Fix CLI v2.1.0\n`);
|
|
process.stderr.write(`Target: ${resolvedPath}\n`);
|
|
process.stderr.write(`Mode: ${apply ? 'APPLY' : 'DRY-RUN'}\n\n`);
|
|
process.stderr.write(`Scanning...\n`);
|
|
}
|
|
|
|
// 1. Run all scanners
|
|
const envelope = await runAllScanners(targetPath, {
|
|
includeGlobal,
|
|
humanizedProgress: !machineMode,
|
|
});
|
|
|
|
// 2. Plan fixes
|
|
const { fixes, skipped, manual } = planFixes(envelope);
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(`\n`);
|
|
process.stderr.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
|
process.stderr.write(` Config-Audit Fix Plan\n`);
|
|
process.stderr.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`);
|
|
|
|
if (fixes.length > 0) {
|
|
process.stderr.write(` Auto-fixable (${fixes.length}):\n`);
|
|
for (let i = 0; i < fixes.length; i++) {
|
|
process.stderr.write(` ${i + 1}. [${fixes[i].findingId}] ${fixes[i].description}\n`);
|
|
}
|
|
} else {
|
|
process.stderr.write(` No auto-fixable issues found.\n`);
|
|
}
|
|
|
|
if (manual.length > 0) {
|
|
// Default mode humanizes the manual-finding titles for the prose render.
|
|
// The JSON `manual` array (later in this function) keeps v5.0.0 verbatim.
|
|
process.stderr.write(`\n Manual (${manual.length}):\n`);
|
|
for (let i = 0; i < manual.length; i++) {
|
|
const m = manual[i];
|
|
const title = humanizeFinding({
|
|
id: m.findingId,
|
|
scanner: typeof m.findingId === 'string' ? m.findingId.split('-')[1] || '' : '',
|
|
severity: m.severity || 'info',
|
|
title: m.title,
|
|
description: m.description || '',
|
|
recommendation: m.recommendation || '',
|
|
}).title;
|
|
process.stderr.write(` ${fixes.length + i + 1}. [${m.findingId}] ${title}\n`);
|
|
}
|
|
}
|
|
|
|
if (skipped.length > 0) {
|
|
process.stderr.write(`\n Skipped (${skipped.length}): could not generate fix plan\n`);
|
|
}
|
|
|
|
process.stderr.write(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
|
}
|
|
|
|
// 3. Apply or dry-run
|
|
let applied = [];
|
|
let failed = [];
|
|
let verified = [];
|
|
let regressions = [];
|
|
let backupId = null;
|
|
// The engine's scope verdict, carried out to the payload. A `disclose` class
|
|
// writes without withholding anything, so the ONLY place the command can
|
|
// learn that a write left the project is here — stderr is discarded by
|
|
// `2>/dev/null` (ux-rules rule 2), which is F3's defect class.
|
|
let scopeGate = null;
|
|
let scopeDisclosures = [];
|
|
|
|
if (fixes.length === 0) {
|
|
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
|
|
if (machineMode) {
|
|
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
}
|
|
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(output, null, 2) + '\n', 'utf-8');
|
|
return;
|
|
}
|
|
|
|
if (apply) {
|
|
// Create backup first. file-rename used to be excluded here, so a rule file
|
|
// whose only defect was its extension was renamed with NO backup entry —
|
|
// while commands/fix.md promised "every fix creates a backup first" and
|
|
// handed the user a backupId that could not restore it. The source file is
|
|
// backed up like any other; rollback recreates it at its original path.
|
|
const filesToBackup = [...new Set(fixes.map(f => f.file))];
|
|
const backup = createBackup(filesToBackup);
|
|
backupId = backup.backupId;
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(`\n Backup created: ${backup.backupPath}\n`);
|
|
process.stderr.write(` Applying ${fixes.length} fixes...\n\n`);
|
|
}
|
|
|
|
const result = await applyFixes(fixes, {
|
|
dryRun: false,
|
|
backupDir: backup.backupPath,
|
|
repoRoot,
|
|
approveScope,
|
|
});
|
|
applied = result.applied;
|
|
failed = result.failed;
|
|
scopeGate = result.gate ?? null;
|
|
scopeDisclosures = result.disclosures ?? [];
|
|
|
|
// A refused set is a verdict about a config that WAS examined, not a tool
|
|
// failure — so it rides in the payload and keeps the normal exit contract
|
|
// (#62). Anything a command must act on has to reach it through
|
|
// `--output-file`; stderr alone is invisible to the command layer (F3).
|
|
if (result.requiresApproval && result.refused.length > 0) {
|
|
const payload = {
|
|
status: 'refused',
|
|
reason: 'scope-gate',
|
|
gate: result.gate,
|
|
requiresApproval: true,
|
|
disclosures: result.disclosures,
|
|
refused: result.refused,
|
|
backupId,
|
|
};
|
|
const json = JSON.stringify(payload, null, 2) + '\n';
|
|
if (machineMode) process.stdout.write(json);
|
|
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
|
if (!machineMode) {
|
|
for (const line of result.disclosures) process.stderr.write(`\n ${line}\n`);
|
|
process.stderr.write(
|
|
`\n Refused ${result.refused.length} fix(es) pending your go-ahead.`
|
|
+ ' Re-run with --approve-scope to apply them.\n',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(` Results: ${applied.length} applied, ${failed.length} failed\n`);
|
|
if (failed.length > 0) {
|
|
for (const f of failed) {
|
|
process.stderr.write(` FAILED: [${f.findingId}] ${f.error}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Verify
|
|
if (applied.length > 0) {
|
|
if (!machineMode) {
|
|
process.stderr.write(`\n Verifying...\n`);
|
|
}
|
|
|
|
// Verification must re-scan the scope the fix run used. It hardcoded
|
|
// includeGlobal:false, so with --global every untouched global-scope
|
|
// finding fell out of the re-scan and was reported as verified.
|
|
const verification = await verifyFixes(envelope, applied, { includeGlobal });
|
|
verified = verification.verified;
|
|
regressions = verification.regressions;
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(` Verified: ${verified.length}/${applied.length}\n`);
|
|
if (regressions.length > 0) {
|
|
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
|
|
}
|
|
// There is no rollback-cli.mjs — the restore path is the command, which
|
|
// drives rollback-engine.mjs. Pointing at a nonexistent script in the
|
|
// one message a user reaches for after a bad fix is the worst place for
|
|
// a dead reference.
|
|
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
|
|
}
|
|
}
|
|
} else {
|
|
// Dry-run mode
|
|
const result = await applyFixes(fixes, { dryRun: true });
|
|
applied = result.applied;
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(`\n Dry-run complete. Pass --apply to execute.\n`);
|
|
}
|
|
}
|
|
|
|
// JSON output (both --json and --raw write byte-equal v5.0.0-shape stdout)
|
|
{
|
|
const output = {
|
|
planned: fixes.map(f => ({
|
|
findingId: f.findingId,
|
|
file: f.file,
|
|
type: f.type,
|
|
description: f.description,
|
|
})),
|
|
applied: applied.map(a => ({
|
|
findingId: a.findingId,
|
|
file: a.file,
|
|
status: a.status,
|
|
})),
|
|
failed: failed.map(f => ({
|
|
findingId: f.findingId,
|
|
file: f.file,
|
|
status: f.status,
|
|
error: f.error,
|
|
})),
|
|
verified,
|
|
regressions,
|
|
manual: manual.map(m => ({
|
|
findingId: m.findingId,
|
|
title: m.title,
|
|
recommendation: m.recommendation,
|
|
})),
|
|
backupId,
|
|
gate: scopeGate,
|
|
disclosures: scopeDisclosures,
|
|
};
|
|
const serialized = JSON.stringify(output, null, 2) + '\n';
|
|
if (machineMode) process.stdout.write(serialized);
|
|
// --output-file carries the same payload to disk. ux-rules rule 2 requires
|
|
// it: commands run scanners with `2>/dev/null`, so anything the command has
|
|
// to act on must ride in a file, not in stdout or stderr.
|
|
if (outputFile) await writeOutputFile(outputFile, serialized, 'utf-8');
|
|
|
|
// Exit code follows the convention the other scanners use: 0 PASS,
|
|
// 2 FAIL, 3 tool error. A failed fix used to exit 0, so a caller could not
|
|
// tell a clean run from one that silently lost a fix.
|
|
if (failed.length > 0) process.exitCode = 2;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
});
|
|
}
|