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
This commit is contained in:
Kjell Tore Guttormsen 2026-08-12 21:15:16 +02:00
commit 749b710de7
16 changed files with 520 additions and 34 deletions

View file

@ -95,6 +95,30 @@ Coding style: scanners are zero-dependency Node ESM; new findings use the `CA-{S
**Write-scope gate (invariant).** Every write target is classified by `scanners/lib/write-scope.mjs` before it reaches an approval surface, and the **scope class decides the gate's strength — never the command asking**. Five command-owned policies would drift apart the way five copies of the lever table did. `SCOPE_CLASSES` is the single source for class, gate (`silent`/`disclose`/`require-ok`), wording and predicate; templates render `disclosures[]` from `write-scope-cli.mjs` rather than restating what a class means. Two orderings in that object are load-bearing and were measured, not reasoned about: `plugin-managed` before `user-scope` (both `~/.claude/config-audit/` and legacy `~/.config-audit/` are live, so the other order fires the gate on every session write and gets it switched off), and `user-scope` before `cross-repo` (`~/.claude/.git` exists, so a plain `.git`-upward walk calls `~/.claude/CLAUDE.md` merely "another repo" and silently downgrades the strongest gate). `disclose``require-ok`: `campaign export` is cross-repo *by design*, so tightening it into a refusal breaks the feature. Distinct from the `require-target-dir.mjs` guard, which asks whether a scan **root** is readable (exit 3) — a different invariant, not to be merged.
**Write-gate coverage (invariant).** The gate above only counts where it is *called*, and for four
releases it was called from prose: `write-scope.mjs` existed, but exactly one writer imported it
(`lib/subtraction-write.mjs`) while five command templates paraphrased the policy. Measured
2026-08-12: 9 files under `scanners/` write to disk, 1 imported the gate. The defect was never
"8 ungated writers = 8 bugs" — four of them write the plugin's own bookkeeping and MUST stay
ungated, because a gate that fires on every run gets switched off. The defect is that **nothing
declared which**, so the question was answered by reading, and answered differently each time.
`tests/lib/write-gate-coverage.test.mjs` is now the authority: every writer must either import
the gate or hold an `EXEMPT` entry naming **where the bytes land**. Three properties are
load-bearing. (1) **A rationale is a claim, not a label**`scan-orchestrator` was carried in
the plan text as exempt while `--save-baseline` derived its path from the *scan target*, so
`--global` landed `~/.claude/.config-audit-baseline.json` (`user-scope`/`require-ok`); it is
gated, and `lib/baseline.mjs` — which writes only under `~/.config-audit/baselines` — is the
genuinely exempt one. (2) **Sync variants count**: `writeFile(` does not match `writeFileSync(`,
and `lib/backup.mjs` uses only the sync forms, so the first sweep scored a real writer as clean
and was green on its own subject. (3) **The sweep asserts non-emptiness** — a regex that stops
matching makes every other assertion here vacuously green. The exemption table is stale-checked
in both directions: an entry naming a file that no longer writes, or one that has since been
gated, fails. `evaluateWriteTargets` in `write-scope.mjs` is the one reduction (classify →
`strongestGate` → dedup disclosures) that all five call sites share; four copies of those four
lines is the drift shape `SCOPE_CLASSES` exists to prevent one level down. Approval is carried by
`--approve-scope`, and **classifying is not approving**: a template that sets the flag because it
already ran `write-scope-cli` has rebuilt the prose contract this guard replaced.
**Dead-prose-reference silence list (invariant).** `CA-CML-013` is a precision-first check, so its
design lives in what it *declines* to flag, and that list is measured (407 real CLAUDE.md files),
never argued. Three rules are load-bearing and each has a guard seen red against its own defect.