config-audit/commands/rollback.md
Kjell Tore Guttormsen 44b222859e 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
2026-08-18 21:28:15 +02:00

7 KiB

name description argument-hint allowed-tools model
config-audit:rollback Restore configuration from backup — list available backups or rollback a specific one [backup-id] Read, Write, Glob, Grep, Bash, AskUserQuestion sonnet

Config-Audit: Rollback

Restore configuration files from a previous backup. Without arguments, lists available backups. With a backup ID, restores files from that backup.

Every step below runs scanners/rollback-cli.mjs, which drives the rollback engine: it verifies each file's checksum before AND after writing it, resolves backups made under the pre-v2.2.0 root, and reports the files a restore cannot undo. Never restore by copying files back by hand — a copy performs none of those checks, and the result cannot honestly be reported as verified.

Arguments

  • $ARGUMENTS may contain a backup ID (format: YYYYMMDD_HHMMSS)
  • --raw: pass-through flag accepted for CLI surface consistency. Rollback is file restoration only (no scanner output, no findings prose), so --raw is a no-op here, but the flag is still parsed so users get uniform behaviour across the toolchain.

Behavior

List mode (no argument)

RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --list --output-file /tmp/config-audit-rollback-list.json 2>/dev/null; echo $?

Exit 0 = listed; 3 = the CLI could not do its job (show the stderr message). Read /tmp/config-audit-rollback-list.json and render one row per entry in backups[]{id}, how many {files} it holds, and {createdAt}. An entry whose {legacy} is true was made under the pre-v2.2.0 backup root; say so, since its path differs from the one printed below.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Available Backups
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

 1. 20260403_163045 — 3 files (settings.json, hooks.json, typescript.md)
 2. 20260403_141230 — 1 file (CLAUDE.md)
 3. 20260402_092015 — 5 files (full audit)

 Usage: /config-audit rollback 20260403_163045
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

If {count} is 0, say there are no backups yet and stop — do not offer a restore.

Restore mode (with backup ID)

  1. The backups[] entry for that ID (from the list payload above) carries the files[] this restore would write. Classify those {originalPath} values before showing them. A restore writes to the absolute path recorded at backup time, which may be machine-wide even when the backup was taken from a project — so the file list must be rendered as the absolute originals, never shortened to a repo-relative-looking form that implies the write stays local:

    node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<original-1>" --target "<original-2>" --repo "$PWD" --output-file /tmp/config-audit-rollback-scope.json 2>/dev/null; echo $?
    

    Exit 0 = classified; 3 = argument error (show the stderr message). Read /tmp/config-audit-rollback-scope.json, render each distinct string in disclosures[] verbatim, then ask for confirmation.

    When requiresApproval is false:

    AskUserQuestion:
      question: "Restore 3 files from backup 20260403_163045?"
      options:
        - "Yes, restore"
        - "Cancel"
    

    When requiresApproval is true, name the scope and put the safe option first:

    AskUserQuestion:
      question: "This restores {K} of 3 files to locations outside this project. Restore all 3?"
      options:
        - "Cancel"
        - "Yes — restore, including outside this project"
    
  2. Run the restore. Classifying is not approving: add --approve-scope only after the user has answered yes to the question above, and only then.

    node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --restore "<backup-id>" --repo "$PWD" --output-file /tmp/config-audit-rollback-restore.json 2>/dev/null; echo $?
    

    Append --approve-scope to that command when the user approved a restore that leaves this project. To preview without writing anything, append --dry-run instead — a dry run reports what would happen and touches no file.

    Exit Meaning
    0 every file restored
    1 nothing was written — the restore needs approval it was not given
    2 at least one file failed; read failed[] before saying anything else
    3 the CLI could not run (bad ID, unreadable manifest) — show the stderr message
  3. Read /tmp/config-audit-rollback-restore.json and report what the run actually did. Render one line per entry in restored[] and failed[], each showing its own {status} from the payload — never a fixed verification phrase, because the outcome differs per file and only the payload knows it:

    Restored 2 of 3 files from backup 20260403_163045
    - /abs/path/.claude/settings.json — {status}
    - /abs/path/hooks/hooks.json — {status}
    - /abs/path/.claude/rules/typescript.md — {status}
    

    When {requiresApproval} is true and nothing was restored, the run was refused: list the refused[] paths, say plainly that no file was changed, and offer to re-run with approval.

  4. Report what rollback cannot undo. A backup only holds files that already existed, so files the implement step CREATED survive the restore. When createdNotRemoved is non-empty, list those paths and say plainly that they remain:

    Left in place — created by implement, no backup exists:
    - .claude/rules/post-quality.md
    - guidelines/posting-rhythm.md
    Remove them manually if you want the pre-implement state exactly.
    

    Never finish a restore without this section when the list is non-empty; a silently half-restored target reads as a clean rollback.

Delete mode

If the user says "delete" after listing, confirm, then:

node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --delete "<backup-id>" --output-file /tmp/config-audit-rollback-delete.json 2>/dev/null; echo $?

Exit 0 = removed (deleted is true in the payload); 3 = no backup with that ID in either root — show the stderr message rather than reporting a deletion.

Implementation

scanners/rollback-cli.mjs is the only entry point. It reads ~/.claude/config-audit/backups and falls back to the pre-v2.2.0 ~/.config-audit/backups, so a backup made before the move still resolves; the list payload flags those with legacy: true, and both roots are echoed in meta so a report can name the one it used.

The same CLI creates backups (--create --target <path> …), which is how /config-audit implement records what it is about to change. Backup and restore therefore share one manifest format, owned by scanners/lib/backup.mjs — the format is never written or read by hand.