`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
227 lines
6.8 KiB
JavaScript
227 lines
6.8 KiB
JavaScript
/**
|
|
* Config-Audit Rollback Engine
|
|
* Restores configuration from backup with checksum verification.
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { readFile, writeFile, readdir, stat, rm } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { getBackupDir, getLegacyBackupDir, parseManifest, checksum } from './lib/backup.mjs';
|
|
import { evaluateWriteTargets } from './lib/write-scope.mjs';
|
|
|
|
/**
|
|
* Resolve a backup id to its directory, canonical root first, then the
|
|
* pre-v2.2.0 root. Returns null when the id exists in neither.
|
|
* @param {string} backupId
|
|
* @returns {Promise<{ path: string, legacy: boolean } | null>}
|
|
*/
|
|
async function resolveBackupPath(backupId) {
|
|
for (const [root, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
|
|
const candidate = join(root, backupId);
|
|
try {
|
|
await stat(join(candidate, 'manifest.yaml'));
|
|
return { path: candidate, legacy };
|
|
} catch {
|
|
// try the next root
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* List all available backups.
|
|
* @returns {Promise<{ backups: object[] }>}
|
|
*/
|
|
export async function listBackups() {
|
|
const backups = [];
|
|
const seen = new Set();
|
|
|
|
// Canonical root first; a legacy backup with the same id must not shadow it.
|
|
for (const [backupRoot, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(backupRoot, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || seen.has(entry.name)) continue;
|
|
|
|
const backupPath = join(backupRoot, entry.name);
|
|
const manifestPath = join(backupPath, 'manifest.yaml');
|
|
|
|
try {
|
|
const manifestContent = await readFile(manifestPath, 'utf-8');
|
|
const manifest = parseManifest(manifestContent);
|
|
|
|
seen.add(entry.name);
|
|
backups.push({
|
|
id: entry.name,
|
|
createdAt: manifest.created_at,
|
|
legacy,
|
|
files: manifest.files.map(f => ({
|
|
originalPath: f.originalPath,
|
|
backupPath: f.backupPath,
|
|
checksum: f.checksum,
|
|
sizeBytes: f.sizeBytes,
|
|
})),
|
|
created: manifest.created,
|
|
});
|
|
} catch {
|
|
// Skip backups without valid manifest
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort newest first
|
|
backups.sort((a, b) => b.id.localeCompare(a.id));
|
|
|
|
return { backups };
|
|
}
|
|
|
|
/**
|
|
* Restore files from a backup.
|
|
* @param {string} backupId
|
|
* @param {object} [opts]
|
|
* @param {boolean} [opts.dryRun=false]
|
|
* @param {boolean} [opts.verify=true]
|
|
* @returns {Promise<{ restored: object[], failed: object[] }>}
|
|
*/
|
|
export async function restoreBackup(backupId, opts = {}) {
|
|
const verify = opts.verify !== false;
|
|
const resolved = await resolveBackupPath(backupId);
|
|
if (!resolved) throw new Error(`Backup not found: ${backupId}`);
|
|
|
|
const backupPath = resolved.path;
|
|
const manifestContent = await readFile(join(backupPath, 'manifest.yaml'), 'utf-8');
|
|
|
|
const manifest = parseManifest(manifestContent);
|
|
const restored = [];
|
|
const failed = [];
|
|
|
|
// Q1 — the scope gate, in code. `rollback` is one of the five arms M-BUG-41
|
|
// measured: it renders repo-relative-looking paths while writing to the
|
|
// ABSOLUTE originals recorded in the manifest, so what the operator reads and
|
|
// what the run touches are not the same set. A backup taken under `--global`
|
|
// restores `~/.claude/…`, which is `user-scope` / `require-ok`.
|
|
const scope = evaluateWriteTargets(
|
|
manifest.files.map((f) => f.originalPath),
|
|
opts.repoRoot ?? null,
|
|
opts.home ? { home: opts.home } : {},
|
|
);
|
|
|
|
// The gate guards a WRITE; a dry run is not one (#63). `requiresApproval` is
|
|
// returned either way, so a caller previewing a restore still learns that
|
|
// approval will be owed.
|
|
if (scope.requiresApproval && !opts.approveScope && !opts.dryRun) {
|
|
return {
|
|
restored: [],
|
|
failed: [],
|
|
gate: scope.gate,
|
|
requiresApproval: true,
|
|
disclosures: scope.disclosures,
|
|
refused: manifest.files.map((f) => ({
|
|
originalPath: f.originalPath,
|
|
status: 'refused',
|
|
reason: 'scope-gate',
|
|
})),
|
|
};
|
|
}
|
|
|
|
// A manifest with entries that parsed to nothing would restore nothing while
|
|
// reporting success. Fail loudly instead.
|
|
if (manifest.files.length === 0 && /^\s+-\s/m.test(manifestContent)) {
|
|
throw new Error(`Unreadable manifest for backup ${backupId}: entries present but none parsed`);
|
|
}
|
|
|
|
for (const fileEntry of manifest.files) {
|
|
const backupFilePath = join(backupPath, fileEntry.backupPath);
|
|
|
|
if (opts.dryRun) {
|
|
restored.push({
|
|
originalPath: fileEntry.originalPath,
|
|
status: 'dry-run',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// Read backup file
|
|
const content = await readFile(backupFilePath);
|
|
|
|
// Verify checksum before restoring
|
|
if (verify) {
|
|
const hash = checksum(content);
|
|
if (hash !== fileEntry.checksum) {
|
|
failed.push({
|
|
originalPath: fileEntry.originalPath,
|
|
status: 'checksum-mismatch',
|
|
error: `Expected ${fileEntry.checksum}, got ${hash}`,
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Write to original path
|
|
await writeFile(fileEntry.originalPath, content);
|
|
|
|
// Verify after write
|
|
if (verify) {
|
|
const written = await readFile(fileEntry.originalPath);
|
|
const writtenHash = checksum(written);
|
|
if (writtenHash !== fileEntry.checksum) {
|
|
failed.push({
|
|
originalPath: fileEntry.originalPath,
|
|
status: 'checksum-mismatch',
|
|
error: 'Checksum mismatch after write',
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
restored.push({
|
|
originalPath: fileEntry.originalPath,
|
|
status: 'restored',
|
|
});
|
|
} catch (err) {
|
|
failed.push({
|
|
originalPath: fileEntry.originalPath,
|
|
status: 'failed',
|
|
error: err.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Files implement CREATED are absent from the backup by definition, so they
|
|
// survive the restore. Report them — a half-restored target is only dangerous
|
|
// when it is also silent.
|
|
return {
|
|
restored,
|
|
failed,
|
|
createdNotRemoved: manifest.created,
|
|
legacy: resolved.legacy,
|
|
gate: scope.gate,
|
|
requiresApproval: scope.requiresApproval,
|
|
disclosures: scope.disclosures,
|
|
refused: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delete a backup directory.
|
|
* @param {string} backupId
|
|
* @returns {Promise<{ deleted: boolean, error?: string }>}
|
|
*/
|
|
export async function deleteBackup(backupId) {
|
|
const resolved = await resolveBackupPath(backupId);
|
|
if (!resolved) return { deleted: false, error: `Backup not found: ${backupId}` };
|
|
|
|
try {
|
|
await rm(resolved.path, { recursive: true, force: true });
|
|
return { deleted: true };
|
|
} catch (err) {
|
|
return { deleted: false, error: err.message };
|
|
}
|
|
}
|