config-audit/commands/fix.md
Kjell Tore Guttormsen 749b710de7 feat(scanners): the write gate now runs in code, not in the templates' prose
`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
2026-08-12 21:15:16 +02:00

8.7 KiB

name description argument-hint allowed-tools model
config-audit:fix Auto-fix deterministic configuration issues with backup and verification [path] [--dry-run] Read, Write, Glob, Grep, Bash, AskUserQuestion sonnet

Config-Audit: Fix

Auto-fix deterministic configuration issues. Scans, plans fixes, backs up originals, applies changes, and verifies results.

Arguments

  • $ARGUMENTS may contain:
    • A target path (default: current working directory)
    • --dry-run: Show fix plan without applying
    • --global: Include user-scope config (~/.claude) in the scan and the fix run
    • --raw: Pass-through to scanners; produces v5.0.0 verbatim envelope (bypasses the humanizer) for byte-stable diff tooling

--global must be passed to every step below. The scan that builds the table and the scan that plans the fixes are two different runs; if only one of them sees the user scope, the plan and the table describe different config.

Implementation

Step 1: Greet and scan

Tell the user:

## Config-Audit Fix

Scanning for auto-fixable issues...

Parse flags and run scanners silently. Default mode emits humanized JSON — each finding carries userImpactCategory, userActionLanguage, and relevanceContext alongside the v5.0.0 fields:

RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
# Set to --global when the user asked for global scope, otherwise leave empty.
# A placeholder in square brackets does not start with a dash, so the arg loop
# would take it as the scan/fix TARGET instead of a flag.
GLOBAL_FLAG=""
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs "<path>" --output-file /tmp/config-audit-fix-scan.json $GLOBAL_FLAG $RAW_FLAG >/dev/null 2>/dev/null; echo $?

Exit code 3 → tell user: "Scanner error. Try /config-audit posture to check your configuration."

Step 2: Plan fixes

Run fix planner silently. The fix-cli emits humanized prose to stderr in default mode and v5.0.0-shape JSON to stdout when --json is set; we use --json here for structured data and let the humanizer-aware rendering layer (this command's prose output below) supply the plain-language wording from the scan envelope above:

# Re-assign here: each fenced block is its own Bash call, so the value
# set in Step 1 is empty by the time this block runs.
GLOBAL_FLAG=""          # --global when the user asked for global scope
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs "<path>" $GLOBAL_FLAG --output-file /tmp/config-audit-fix-plan.json 2>/dev/null; echo $?

Exit codes: 0 = plan produced, 2 = one or more fixes failed (apply step only), 3 = argument or tool error. On 3, show the stderr message — an unknown flag is rejected by design, not silently ignored.

Read /tmp/config-audit-fix-plan.json using the Read tool. Cross-reference each fix-plan entry against the humanized scan envelope (/tmp/config-audit-fix-scan.json) by finding ID to recover the humanized title/description/recommendation plus userImpactCategory/userActionLanguage for grouping.

Step 3: Present fix plan

First classify where the auto-fixable entries write. With --global the run takes ~/.claude into the fix pass, so machine-wide and project rows land in one table; without a marker they read as equally local. Pass one --target per distinct file in the auto-fixable set:

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

Exit 0 = classified; 3 = argument error (show the stderr message). Read /tmp/config-audit-fix-scope.json and carry each file's scopeClass into the table below.

Show what will be fixed and what needs manual attention. Group by userActionLanguage so the urgency phrasing stays consistent with the rest of the toolchain:

### Fix Plan

**Auto-fixable ({N} issues), grouped by impact:**

{For each userActionLanguage bucket in priority order — "Fix this now" → "Fix soon" → "Fix when convenient" → "Optional cleanup" → "FYI":}

#### {userActionLanguage}

| # | ID | Issue | File | Scope |
|---|-----|-------|------|-------|
| 1 | {id} | {humanized title} | {file} | {scopeClass, or blank when "in-repo"} |

**Manual ({M} issues — require human judgment), grouped by impact:**

{Same userActionLanguage grouping. Render humanized title and recommendation verbatim — the humanizer already produced plain-language strings, do not paraphrase.}

| # | ID | Issue | Recommendation |
|---|-----|-------|----------------|
| 1 | {id} | {humanized title} | {humanized recommendation} |

Step 4: Confirm with user

If not --dry-run, ask for confirmation. Render each distinct string in the scope payload's disclosures[] verbatim first.

When requiresApproval is false:

AskUserQuestion:
  question: "Apply {N} auto-fixes? A backup is created first — you can roll back anytime."
  options:
    - "Yes, apply fixes"
    - "Show dry-run only"
    - "Cancel"

When requiresApproval is true — which is what --global produces, since ~/.claude is machine-wide — the question MUST say so and the safe option MUST come first:

AskUserQuestion:
  question: "{K} of {N} fixes change configuration outside this project. Apply all {N}?"
  options:
    - "Show dry-run only"
    - "Yes — apply all, including outside this project"
    - "Cancel"

Step 5: Apply fixes

If confirmed, apply:

# Re-assign here: each fenced block is its own Bash call, so the value
# set in Step 1 is empty by the time this block runs.
GLOBAL_FLAG=""          # --global when the user asked for global scope
# --approve-scope carries the answer the user just gave to the Step-4 question.
# The engine runs the same scope gate as Step 3 and withholds a `require-ok`
# write on its own, so leaving this empty after the user answered "Yes — apply
# all, including outside this project" makes the run refuse the very fixes they
# approved. Set it ONLY on that answer — never as a default, and never because
# Step 3 already classified the targets: classifying is not approving.
APPROVE_SCOPE=""        # --approve-scope when the user approved the outside-project fixes
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs "<path>" --apply $GLOBAL_FLAG $APPROVE_SCOPE --output-file /tmp/config-audit-fix-applied.json 2>/dev/null; echo $?

Read /tmp/config-audit-fix-applied.json with the Read tool to get applied/failed counts and the backup ID. Exit code 2 means at least one fix failed — report it; failed[] carries the reason per fix.

The payload also carries the engine's own scope verdict. When it reads "status": "refused" with "reason": "scope-gate", nothing was written: render each line of disclosures verbatim, then ask the Step-4 question again rather than re-running with the flag on the user's behalf. A refusal is a verdict about a config that WAS examined, so the exit code stays in the normal 0/1/2 range — do not report it as a tool error.

Step 6: Show results

Run a quick posture check to measure improvement:

node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<path>" --json --output-file /tmp/config-audit-fix-posture.json >/dev/null 2>/dev/null

Use the Read tool on /tmp/config-audit-fix-posture.json and take overallGrade and the score from there. That read is the only source for the numbers below: --json prints the same envelope to stdout, but 255 KB of raw JSON in the transcript to recover one grade is exactly the waste this plugin exists to find.

Present results:

### Results

**{applied} fixed** | {failed} failed | Backup created

{If grade improved:}
Score impact: {old_grade} ({old_score}) → {new_grade} ({new_score}) — **+{delta} points**

{If failed > 0:}
{failed} fix(es) couldn't be applied — run `/config-audit plan` for alternative approaches.

**Rollback:** If anything looks wrong, run `/config-audit rollback {backup-id}` to restore.

Step 7: Manual findings

If manual findings exist:

### Needs manual attention

These {M} issues require human judgment:

1. **{title}** ({id}) — {recommendation}
2. ...

Run `/config-audit plan` to get a step-by-step guide for addressing these.

Safety

  • Backup is mandatory — every fix creates a backup first, including file renames (the source file is backed up before the rename, so rollback can restore it at its original path)
  • Dry-run by default — user must confirm before changes
  • Verify after fix — re-scans in the same scope the fix run used, so a --global run is verified against user scope too
  • Rollback always available — /config-audit rollback <backup-id>
  • A failed fix is reported, never swallowed — exit 2 plus a failed[] entry