feat(scanners): the recovery path is code you can run, not prose you can read

R1+R2 as one chunk — both KRITISK rows of the Q3 severity table sit on the
restore path, and neither closes alone.

R1: rollback-engine.mjs verified every checksum before AND after each write,
resolved the legacy backup root and reported createdNotRemoved — and none of it
was reachable. Measured: 16 files under scanners/ carry a process.argv entry;
the engine was not one of them. commands/rollback.md drove the restore as model
prose: an ESM import block a template cannot execute, ad-hoc `cp` offered
underneath as the runnable path, and "(checksum verified)" pre-rendered three
times in the success output. `cp` establishes no checksum, so the verification
was a property of the template rather than of the run — on the one surface that
runs when the user is already in trouble.

R2: implement.md Step 3 hand-built its backup (mkdir, cp, a date-derived id, a
manifest typed out in the template) while parseManifest knew one frozen sample
of that format, pinned by a HAND-WRITTEN fixture instead of by the template's
own text. Rename a key and parseManifest returns zero files while rollback
reports success.

Fixing only R1 leaves the new CLI parsing a prose format; fixing only R2 leaves
a clean format with no runnable entry.

- scanners/rollback-cli.mjs — --list / --create / --restore / --delete over the
  existing engine, on the shared requireValidArgs gate. Exit 0 done, 1
  outstanding (gate refusal with nothing written, or a backup that covered fewer
  targets than given), 2 a file failed, 3 could not do the job. A gated restore
  is 1, not 3: "this write leaves your project" is a verdict about a write that
  WAS examined, and it rides in the payload where a command under 2>/dev/null
  can act on it.
- createBackup gains `created` (recorded, never copied — no backup can hold a
  file that does not exist) and `skipped`, so a backup covering fewer files than
  asked is no longer indistinguishable from a clean one.
- implement.md Step 3 and rollback.md now call the CLI. parseManifest's
  implement-format branch stays: nothing writes that shape now, but every backup
  made before this chunk is on disk in it.
- backup-restore-contract.test.mjs checks every field rollback.md renders
  against a payload produced by RUNNING the CLI. That is what replaced
  "(checksum verified)".

20 guards seen red against the original state before any production code, then
each against its own defect. Two holes that surfaced there were mine: the
implement assertion matched `--create` as a substring of `--created` and stayed
green when the call was removed; and mutating the argv gate showed
requireValidArgs sets exit 3 by itself, so a CLI can report that it could not
parse its arguments and still run the restore underneath — that case is now
asserted on the bytes.

Suite 1752 -> 1777, 0 fail. Frozen tests/snapshots/v5.0.0 untouched. Dogfooded
through the templates' own command lines against a sandboxed HOME, including the
machine-wide arm: refused with the file unchanged, then restored under
--approve-scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Logq8GGWKhtyDem63FTEnG
This commit is contained in:
Kjell Tore Guttormsen 2026-08-18 21:28:15 +02:00
commit 44b222859e
13 changed files with 1083 additions and 86 deletions

View file

@ -238,10 +238,10 @@ async function main() {
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.
// The user-facing entry, not the CLI: `/config-audit rollback` renders
// the scope disclosures and asks before a restore that leaves the repo.
// `scanners/rollback-cli.mjs` exists now (R1) and is what the command
// runs, but naming it here would hand the user the ungated half.
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
}
}

View file

@ -70,10 +70,19 @@ export function checksum(content) {
/**
* Create a backup of the specified files.
*
* `opts.created` records paths the caller is about to CREATE. No backup can
* hold a file that does not exist yet, so these are not copied they are
* written into the manifest so `rollback` can tell the user which files it is
* leaving behind. That list used to exist only in `commands/implement.md`,
* typed out by hand next to a manifest the template also typed out by hand;
* moving it here is what lets the template stop owning the format (R2).
*
* @param {string[]} files - Array of absolute file paths to back up
* @param {object} [opts]
* @param {string} [opts.backupId] - Override backup ID (for testing)
* @returns {{ backupId: string, backupPath: string, manifest: object }}
* @param {string[]} [opts.created] - Paths this run will create (recorded, not copied)
* @returns {{ backupId: string, backupPath: string, manifest: object, skipped: string[] }}
*/
export function createBackup(files, opts = {}) {
const backupId = opts.backupId || generateBackupId();
@ -83,9 +92,13 @@ export function createBackup(files, opts = {}) {
mkdirSync(filesDir, { recursive: true });
const manifestFiles = [];
const skipped = [];
for (const file of files) {
if (!existsSync(file)) continue;
// A target that is not there is reported, never silently dropped: the
// caller asked for a backup of N files and must be able to learn it got
// fewer, before it edits anything.
if (!existsSync(file)) { skipped.push(file); continue; }
const safeName = safeFileName(file);
copyFileSync(file, join(filesDir, safeName));
@ -106,6 +119,7 @@ export function createBackup(files, opts = {}) {
created_at: new Date().toISOString(),
backup_id: backupId,
files: manifestFiles,
created: [...(opts.created || [])],
};
// Write manifest as YAML-like format
@ -115,7 +129,7 @@ export function createBackup(files, opts = {}) {
// Cleanup old backups
cleanupOldBackups();
return { backupId, backupPath, manifest };
return { backupId, backupPath, manifest, skipped };
}
/**
@ -133,6 +147,14 @@ function serializeManifest(manifest) {
yaml += ` checksum: "${f.checksum}"\n`;
yaml += ` size_bytes: ${f.sizeBytes}\n`;
}
// Emitted only when non-empty, and read back by `parseManifest`'s `created:`
// branch — the bare-key form, which is why the implement flow's
// `created: <timestamp>` (a VALUE, meaning the backup id) never collides
// with it.
if (manifest.created && manifest.created.length > 0) {
yaml += `created:\n`;
for (const c of manifest.created) yaml += ` - ${c}\n`;
}
return yaml;
}
@ -168,10 +190,13 @@ export function parseManifest(content) {
}
}
// Parse file entries — implement-flow format. `commands/implement.md` has the
// agent hand-build the backup dir, so real manifests on disk use unquoted
// `- backup:` / `original:` / `sha256:`. Reading only the engine format made
// restoreBackup a success-shaped no-op on every backup implement produced.
// Parse file entries — implement-flow format. Until R2, `commands/implement.md`
// had the agent hand-build the backup dir, so manifests written by that flow
// use unquoted `- backup:` / `original:` / `sha256:`. Reading only the engine
// format made restoreBackup a success-shaped no-op on every backup implement
// produced (M-BUG-25). The template no longer writes this format, but the
// branch stays: backups already on disk in it must remain restorable — the
// same reason `getLegacyBackupDir()` is still read.
if (result.files.length === 0) {
const implBlocks = content.split(/\n\s+-\s+backup:/).slice(1);
for (const block of implBlocks) {

208
scanners/rollback-cli.mjs Normal file
View file

@ -0,0 +1,208 @@
#!/usr/bin/env node
/**
* Config-Audit Rollback CLI the runnable entry to the backup/restore engine.
*
* `rollback-engine.mjs` has verified checksums before AND after each write,
* resolved the pre-v2.2.0 backup root and reported `createdNotRemoved` since
* M-BUG-22/M-BUG-25. None of it was reachable: measured at the head of this
* chunk, 16 files under `scanners/` carried a `process.argv` entry and the
* engine was not one of them. `commands/rollback.md` drove the restore as model
* prose an ESM `import` block a template cannot execute, with ad-hoc `cp`
* offered underneath as the runnable alternative and "(checksum verified)"
* pre-rendered in the success output. `cp` establishes no checksum, so the
* verification was a property of the template rather than of the run (R1).
*
* `--create` lives here for the same reason `--restore` does. The implement
* pipeline used to build its backup by hand `mkdir`, `cp`, a `date`-derived
* id and a manifest typed out in the template while `parseManifest` knew one
* frozen sample of that format, pinned by a hand-written fixture rather than by
* the template's own text. One side of that contract was maintained by editing
* prose (R2). Now both sides are `lib/backup.mjs`.
*
* Usage:
* node rollback-cli.mjs [--list]
* node rollback-cli.mjs --restore <backup-id> [--dry-run] [--approve-scope]
* node rollback-cli.mjs --delete <backup-id>
* node rollback-cli.mjs --create --target <path> [--target <path> ...]
* [--created <path> ...] [--backup-id <id>]
* ... plus [--repo <session-root>] [--output-file <path>] [--json]
*
* Exit codes:
* 0 done, nothing owed
* 1 the run completed but something is outstanding a restore the scope
* gate will not perform without `--approve-scope` (nothing was written),
* or a backup that covered fewer targets than it was given
* 2 at least one file failed to restore (checksum mismatch or write error)
* 3 the CLI could not do its job malformed argv, or a backup id that
* resolves in neither root
*
* A gated restore is exit 1, not 3: "this write leaves your project" is a
* verdict about a write that WAS examined, and it rides in the payload where a
* command can act on it. Anything that only reaches stderr is invisible to a
* command running under `2>/dev/null` (F3's class).
*
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
import { createBackup, getBackupDir, getLegacyBackupDir } from './lib/backup.mjs';
import { listBackups, restoreBackup, deleteBackup } from './rollback-engine.mjs';
/** Flag surface. Anything else is exit 3. */
const ARG_SPEC = {
boolean: ['--list', '--create', '--dry-run', '--approve-scope', '--json'],
value: ['--restore', '--delete', '--target', '--created', '--backup-id', '--repo', '--output-file'],
};
/** The mode flags, in the order a diagnostic should name them. */
const MODES = ['--list', '--create', '--restore', '--delete'];
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exitCode = 3;
}
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
const targets = [];
const created = [];
let mode = null;
let backupId = null;
let dryRun = false;
let approveScope = false;
let jsonMode = false;
let outputFile = null;
let overrideId = null;
// The session's root, never a path derived from the backup (#63). A restore
// writes to the ABSOLUTE originals recorded at backup time, so reading the
// root off those paths would call a machine-wide write "in-repo" and silence
// the strongest gate exactly where it matters.
let repoRoot = process.cwd();
const setMode = (flag) => {
if (mode !== null && mode !== flag) return false;
mode = flag;
return true;
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (MODES.includes(a)) {
if (!setMode(a)) {
// Silently letting the last one win is how a `--delete` rides along
// behind a `--list` the caller thought it was running.
fail(`only one mode may be given (${MODES.join(', ')}); saw "${mode}" and "${a}"`);
return;
}
if (a === '--restore' || a === '--delete') backupId = args[++i];
} else if (a === '--target') targets.push(args[++i]);
else if (a === '--created') created.push(args[++i]);
else if (a === '--backup-id') overrideId = args[++i];
else if (a === '--repo') repoRoot = args[++i];
else if (a === '--output-file') outputFile = args[++i];
else if (a === '--dry-run') dryRun = true;
else if (a === '--approve-scope') approveScope = true;
else if (a === '--json') jsonMode = true;
}
if (mode === null) mode = '--list';
const meta = {
mode: mode.slice(2),
backupRoot: getBackupDir(),
legacyBackupRoot: getLegacyBackupDir(),
repo: resolve(repoRoot),
};
let payload;
let lines = [];
if (mode === '--list') {
const { backups } = await listBackups();
payload = { meta, count: backups.length, backups };
lines = backups.map((b) => `${b.id}\t${b.files.length}\t${b.legacy ? 'legacy' : 'current'}`);
} else if (mode === '--create') {
if (targets.length === 0) {
fail('--create needs at least one --target');
return;
}
const result = createBackup(targets, {
...(overrideId ? { backupId: overrideId } : {}),
created,
});
payload = {
meta,
backupId: result.backupId,
backupPath: result.backupPath,
files: result.manifest.files,
created: result.manifest.created,
skipped: result.skipped,
};
lines = [`${result.backupId}\t${result.manifest.files.length}\t${result.skipped.length}`];
// A backup that covers fewer files than it was asked for is the state the
// caller must not mistake for a clean one: it is about to edit a file it
// cannot roll back.
if (result.skipped.length > 0) process.exitCode = 1;
} else if (mode === '--delete') {
const result = await deleteBackup(backupId);
if (!result.deleted) {
fail(result.error);
return;
}
payload = { meta, backupId, deleted: true, error: null };
lines = [`${backupId}\tdeleted`];
} else {
let result;
try {
result = await restoreBackup(backupId, { dryRun, approveScope, repoRoot });
} catch (err) {
// "Backup not found" and "unreadable manifest" are both the CLI failing to
// do its job, never a verdict about a restore that happened.
fail(err.message);
return;
}
payload = {
meta,
backupId,
dryRun,
gate: result.gate,
requiresApproval: result.requiresApproval,
disclosures: result.disclosures,
restored: result.restored,
failed: result.failed,
refused: result.refused,
createdNotRemoved: result.createdNotRemoved ?? [],
legacy: result.legacy ?? false,
};
lines = [
...result.restored.map((r) => `${r.status}\t${r.originalPath}`),
...result.failed.map((f) => `${f.status}\t${f.originalPath}`),
...result.refused.map((r) => `${r.status}\t${r.originalPath}`),
];
if (result.failed.length > 0) process.exitCode = 2;
else if (payload.requiresApproval && !approveScope && !dryRun) process.exitCode = 1;
}
const json = `${JSON.stringify(payload, null, 2)}\n`;
if (outputFile) {
await writeOutputFile(outputFile, json);
// Nothing on stdout when writing to a file — a command rendering this would
// otherwise show the user the raw payload (ux-rules rule 1).
} else if (jsonMode) {
process.stdout.write(json);
} else {
for (const line of lines) process.stdout.write(`${line}\n`);
}
}
try {
await main();
} catch (err) {
process.stderr.write(`Error: ${err.message}\n`);
process.exitCode = 3;
}