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.

View file

@ -291,6 +291,25 @@ Your team configuration changes over time. Track it:
By default, `/config-audit` auto-detects scope from your git context. Override with: `/config-audit current`, `/config-audit repo`, `/config-audit home`, `/config-audit full`. Use `--delta` for incremental scanning (only new/changed findings).
### Where a write is allowed to land
Reading is machine-wide; **writing is not**. Every write target is classified against the project
you are standing in, and the classification — not the command doing the asking — decides how
strong the gate is. A change inside your project applies normally. A write into a *different*
project is disclosed and then applied, because some commands (like `campaign export`) are
cross-repo by design. A write to your machine-wide `~/.claude` configuration, or to a path in no
project at all, is **withheld until you approve that scope explicitly** — it costs, and saves, in
every project on every turn.
That gate now runs inside the engines, not only in the command prose that wraps them. It covers
`/config-audit fix`, `/config-audit rollback`, `campaign export`, `--save-baseline`, and
`optimize --subtract --apply`. When a run is withheld, nothing has been written: you get the
reason and the affected paths, and you re-run with your approval (`--approve-scope` on the CLIs).
Approval is always a separate act — classifying a target is not approving it. The plugin's own
bookkeeping (backups, session state, ledgers, and the report file you named with `--output-file`)
is deliberately exempt: a gate that fired on every run would be switched off, and then it would
guard nothing.
---
## Deterministic Scanners

View file

@ -139,11 +139,25 @@ 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
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs "<path>" --apply $GLOBAL_FLAG --output-file /tmp/config-audit-fix-applied.json 2>/dev/null; echo $?
# --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:

View file

@ -42,6 +42,7 @@ import {
defaultLedgerPath,
} from './lib/campaign-ledger.mjs';
import { planExportPath, buildPlanExportDocument } from './lib/campaign-export.mjs';
import { evaluateWriteTargets } from './lib/write-scope.mjs';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@ -62,7 +63,7 @@ function defaultSessionsDir() {
}
function parseArgs(argv) {
const flags = { repo: null, ledgerFile: null, sessionsDir: null, referenceDate: null, outputFile: null, write: false };
const flags = { repo: null, ledgerFile: null, sessionsDir: null, referenceDate: null, outputFile: null, write: false, approveScope: false, sessionRoot: null };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--repo' && argv[i + 1] !== undefined) flags.repo = argv[++i];
@ -71,6 +72,8 @@ function parseArgs(argv) {
else if (a === '--reference-date' && argv[i + 1] !== undefined) flags.referenceDate = argv[++i];
else if (a === '--output-file' && argv[i + 1] !== undefined) flags.outputFile = argv[++i];
else if (a === '--write') flags.write = true;
else if (a === '--approve-scope') flags.approveScope = true;
else if (a === '--session-root' && argv[i + 1] !== undefined) flags.sessionRoot = argv[++i];
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
else fail(`unexpected argument "${a}"`);
}
@ -147,6 +150,32 @@ async function main() {
now,
});
// Q1 — the scope gate, in code rather than in commands/campaign.md's prose.
//
// `--repo` here names the repo being EXPORTED TO, which is the write target's
// repo, not the session's. The session root is where the operator stands, so
// it comes from `--session-root` (default cwd) — reading it off `--repo`
// would make every export look "in-repo" and silence the gate by
// construction (#63).
//
// Export into another project is `cross-repo`, whose gate is `disclose`, NOT
// `require-ok`: campaign export is cross-repo BY DESIGN, and tightening it
// into a refusal breaks the feature. So the disclosure always rides in the
// payload, and only a `require-ok` class (machine-wide config, or a path in
// no project at all) actually withholds the write.
const scope = evaluateWriteTargets([targetPath], resolve(flags.sessionRoot ?? process.cwd()));
if (flags.write && scope.requiresApproval && !flags.approveScope) {
return emit(
{ status: 'refused', action: 'export', reason: 'scope-gate', repo: repoInfo,
sessionId: repo.sessionId, sourcePlanPath, exportable: true, problems: [],
written: false, targetPath, gate: scope.gate, requiresApproval: true,
disclosures: scope.disclosures },
flags.outputFile,
0,
);
}
let written = false;
if (flags.write) {
await mkdir(dirname(targetPath), { recursive: true });
@ -156,7 +185,9 @@ async function main() {
return emit(
{ status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath,
exportable: true, problems: [], written, targetPath, document },
exportable: true, problems: [], written, targetPath, document,
gate: scope.gate, requiresApproval: scope.requiresApproval,
disclosures: scope.disclosures },
flags.outputFile,
0,
);

View file

@ -19,8 +19,8 @@ import { humanizeFinding } from './lib/humanizer.mjs';
// `--dry-run` is a no-op alias: dry-run is already the default. It exists because
// commands/fix.md documents it in argument-hint, and a documented flag that the
// CLI silently drops is the same fail-silent class as the unknown-flag sink below.
const BOOL_FLAGS = ['--apply', '--dry-run', '--json', '--raw', '--global'];
const VALUE_FLAGS = ['--output-file'];
const BOOL_FLAGS = ['--apply', '--dry-run', '--json', '--raw', '--global', '--approve-scope'];
const VALUE_FLAGS = ['--output-file', '--repo'];
async function main() {
const args = process.argv.slice(2);
@ -30,6 +30,12 @@ async function main() {
let rawMode = false;
let includeGlobal = false;
let outputFile = null;
let approveScope = false;
// The session's root, never the scan target (#63). `--global` fixes files
// under `~/.claude` while the session still stands somewhere else, so reading
// the root off the target would classify a machine-wide write as "in-repo"
// and silence the strongest gate exactly where it matters.
let repoRoot = process.cwd();
// Same defect class as M-BUG-21 in drift-cli: this loop used to end in
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
@ -44,13 +50,15 @@ async function main() {
else if (arg === '--json') jsonMode = true;
else if (arg === '--raw') rawMode = true;
else if (arg === '--global') includeGlobal = true;
else if (arg === '--approve-scope') approveScope = true;
// --dry-run: default behaviour, accepted so it is not silently dropped.
} else if (VALUE_FLAGS.includes(arg)) {
const value = args[i + 1];
if (value === undefined || value.startsWith('-')) {
throw new Error(`Option ${arg} requires a value.`);
}
outputFile = value;
if (arg === '--repo') repoRoot = value;
else outputFile = value;
i++;
} else if (arg.startsWith('-')) {
throw new Error(
@ -134,6 +142,12 @@ async function main() {
let verified = [];
let regressions = [];
let backupId = null;
// The engine's scope verdict, carried out to the payload. A `disclose` class
// writes without withholding anything, so the ONLY place the command can
// learn that a write left the project is here — stderr is discarded by
// `2>/dev/null` (ux-rules rule 2), which is F3's defect class.
let scopeGate = null;
let scopeDisclosures = [];
if (fixes.length === 0) {
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
@ -159,9 +173,43 @@ async function main() {
process.stderr.write(` Applying ${fixes.length} fixes...\n\n`);
}
const result = await applyFixes(fixes, { dryRun: false, backupDir: backup.backupPath });
const result = await applyFixes(fixes, {
dryRun: false,
backupDir: backup.backupPath,
repoRoot,
approveScope,
});
applied = result.applied;
failed = result.failed;
scopeGate = result.gate ?? null;
scopeDisclosures = result.disclosures ?? [];
// A refused set is a verdict about a config that WAS examined, not a tool
// failure — so it rides in the payload and keeps the normal exit contract
// (#62). Anything a command must act on has to reach it through
// `--output-file`; stderr alone is invisible to the command layer (F3).
if (result.requiresApproval && result.refused.length > 0) {
const payload = {
status: 'refused',
reason: 'scope-gate',
gate: result.gate,
requiresApproval: true,
disclosures: result.disclosures,
refused: result.refused,
backupId,
};
const json = JSON.stringify(payload, null, 2) + '\n';
if (machineMode) process.stdout.write(json);
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
if (!machineMode) {
for (const line of result.disclosures) process.stderr.write(`\n ${line}\n`);
process.stderr.write(
`\n Refused ${result.refused.length} fix(es) pending your go-ahead.`
+ ' Re-run with --approve-scope to apply them.\n',
);
}
return;
}
if (!machineMode) {
process.stderr.write(` Results: ${applied.length} applied, ${failed.length} failed\n`);
@ -235,6 +283,8 @@ async function main() {
recommendation: m.recommendation,
})),
backupId,
gate: scopeGate,
disclosures: scopeDisclosures,
};
const serialized = JSON.stringify(output, null, 2) + '\n';
if (machineMode) process.stdout.write(serialized);

View file

@ -8,6 +8,7 @@ import { readFile, writeFile, rename, stat } from 'node:fs/promises';
import { dirname } from 'node:path';
import { parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
import { createBackup } from './lib/backup.mjs';
import { evaluateWriteTargets } from './lib/write-scope.mjs';
import { runAllScanners } from './scan-orchestrator.mjs';
import { VALID_EFFORT_LEVELS as SETTINGS_EFFORT_LEVELS } from './settings-validator.mjs';
@ -237,6 +238,42 @@ export async function applyFixes(fixPlans, opts = {}) {
throw new Error('backupDir is required when not in dryRun mode');
}
// Q1 — the scope gate, in code rather than in the command template's prose.
//
// A file-rename writes TWO paths: the source disappears and `newPath`
// appears. Classifying only `plan.file` would let a rename move a repo file
// to a machine-wide destination under a `silent` gate.
//
// `!dryRun` is load-bearing and is the same rule the subtraction axis
// settled (#63): the gate guards a WRITE, and a dry run is not one.
// `requiresApproval` is reported either way, so a caller planning a run still
// learns that approval will be owed before anything is applied.
const scope = evaluateWriteTargets(
fixPlans.flatMap((p) => (p.newPath ? [p.file, p.newPath] : [p.file])),
opts.repoRoot ?? null,
opts.home ? { home: opts.home } : {},
);
if (scope.requiresApproval && !opts.approveScope && !opts.dryRun) {
// A refused write is a VERDICT about a config that was examined, not a
// tool failure (#62) — the caller renders the disclosure and asks. Nothing
// is applied, and this is not an exit-3 situation.
return {
applied: [],
failed: [],
gate: scope.gate,
requiresApproval: true,
disclosures: scope.disclosures,
refused: fixPlans.map((plan) => ({
findingId: plan.findingId,
file: plan.file,
status: 'refused',
reason: 'scope-gate',
type: plan.type,
})),
};
}
for (const plan of fixPlans) {
if (opts.dryRun) {
applied.push({
@ -269,7 +306,14 @@ export async function applyFixes(fixPlans, opts = {}) {
}
}
return { applied, failed };
return {
applied,
failed,
gate: scope.gate,
requiresApproval: scope.requiresApproval,
disclosures: scope.disclosures,
refused: [],
};
}
/**

View file

@ -165,6 +165,42 @@ export function strongestGate(targets) {
return worst;
}
/**
* Classify a whole write SET and reduce it to one verdict (Q1).
*
* Every gated arm needs the same four things classify each target, take the
* strongest gate, de-duplicate the disclosures, report whether approval is owed
* and `lib/subtraction-write.mjs` was the only arm that had them, written
* inline. Four more call sites copying those four lines is precisely the shape
* `SCOPE_CLASSES` exists to prevent one level down: the copies drift, and the
* drift is invisible because each one still looks correct on its own.
*
* This decides nothing about whether a write is a good idea, and it never
* writes. It answers "what does this set of targets oblige you to say?".
*
* Note the strict default: `sessionRepoRoot` of `null` means the `in-repo`
* class can never match, so an omitted repo root fails toward MORE disclosure,
* not less. A caller that forgets to pass it gets a noisier gate rather than a
* silent one.
*
* @param {string[]} paths - Paths about to be written. Duplicates are fine.
* @param {string|null} sessionRepoRoot - Repo root of the current session.
* @param {object} [options] - Forwarded to `classifyWriteTarget`.
* @returns {{gate: string, requiresApproval: boolean, disclosures: string[], targets: object[]}}
*/
export function evaluateWriteTargets(paths, sessionRepoRoot, options = {}) {
const unique = [...new Set(paths.map((p) => resolve(p)))];
const targets = unique.map((p) => classifyWriteTarget(p, sessionRepoRoot, options));
const gate = strongestGate(targets);
return {
gate,
requiresApproval: gate === 'require-ok',
disclosures: [...new Set(targets.map((t) => t.disclosure).filter(Boolean))],
targets,
};
}
/**
* Classify a write target relative to the repo the session stands in.
*

View file

@ -7,6 +7,7 @@
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
@ -100,6 +101,35 @@ export async function restoreBackup(backupId, opts = {}) {
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)) {
@ -167,7 +197,16 @@ export async function restoreBackup(backupId, opts = {}) {
// 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 };
return {
restored,
failed,
createdNotRemoved: manifest.created,
legacy: resolved.legacy,
gate: scope.gate,
requiresApproval: scope.requiresApproval,
disclosures: scope.disclosures,
refused: [],
};
}
/**

View file

@ -10,6 +10,7 @@
import { resolve, sep } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { evaluateWriteTargets } from './lib/write-scope.mjs';
import { requireTargetDir } from './lib/require-target-dir.mjs';
import { envelope } from './lib/output.mjs';
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
@ -42,6 +43,7 @@ const ARG_SPEC = {
boolean: [
'--json', '--raw', '--global', '--full-machine', '--no-suppress',
'--include-fixtures', '--exclude-cache', '--no-exclude-cache', '--save-baseline',
'--approve-scope',
],
value: ['--output-file', '--context-window', '--baseline'],
};
@ -241,6 +243,7 @@ async function main() {
let targetPath = '.';
let outputFile = null;
let saveBaseline = false;
let approveScope = false;
let baselinePath = null;
let contextWindow = null;
@ -249,6 +252,8 @@ async function main() {
outputFile = args[++i];
} else if (args[i] === '--context-window' && args[i + 1]) {
contextWindow = args[++i];
} else if (args[i] === '--approve-scope') {
approveScope = true;
} else if (args[i] === '--save-baseline') {
saveBaseline = true;
} else if (args[i] === '--baseline' && args[i + 1]) {
@ -314,10 +319,26 @@ async function main() {
if (saveBaseline) {
const bPath = baselinePath || resolve(targetPath, '.config-audit-baseline.json');
// Always save baselines as raw v5.0.0-shape envelope so future humanizer
// changes don't trigger false-positive drift findings.
await writeFile(bPath, JSON.stringify(result, null, 2), 'utf-8');
process.stderr.write(`Baseline saved to ${bPath}\n`);
// Q1 — the scope gate, in code. This write was carried in the plan text as
// one of the plugin's own artifacts, legitimately exempt — measured false: the
// default path is derived from the SCAN TARGET, not from a plugin root, so
// `--global --save-baseline` lands `~/.claude/.config-audit-baseline.json`,
// which is `user-scope` / `require-ok`. `lib/baseline.mjs` is the genuinely
// exempt one — it writes only under `~/.config-audit/baselines`.
const scope = evaluateWriteTargets([bPath], process.cwd());
if (scope.requiresApproval && !approveScope) {
for (const line of scope.disclosures) process.stderr.write(`\n${line}\n`);
process.stderr.write(
`Baseline NOT saved to ${bPath} — re-run with --approve-scope to write it.\n`,
);
} else {
// Always save baselines as raw v5.0.0-shape envelope so future humanizer
// changes don't trigger false-positive drift findings.
await writeFile(bPath, JSON.stringify(result, null, 2), 'utf-8');
process.stderr.write(`Baseline saved to ${bPath}\n`);
}
}
// Summary

View file

@ -0,0 +1,191 @@
/**
* Q1 the write gate, moved from prose into code.
*
* `write-scope.mjs` has existed since M-BUG-41, but only ONE writer in
* `scanners/` ever imported it (`lib/subtraction-write.mjs`). Every other arm
* answered "is this write gated?" the way the five copies of the lever table
* answered "what is a lever?" by reading a command template and trusting its
* prose. 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`.
*
* The defect was never "8 ungated writers = 8 bugs". Four of them legitimately
* write the plugin's own bookkeeping and MUST stay ungated gating them fires
* the gate on every run, which is the failure mode `SCOPE_CLASSES`' own ordering
* exists to prevent ("worse than having no gate"). The defect is that NOTHING
* DECLARED WHICH. The question was answerable only by reading, so it was
* answered differently every time it was asked.
*
* This guard makes the answer structural: every writer must either import the
* gate or appear in `EXEMPT` with a rationale. A new writer that does neither
* fails here, not in review.
*
* Two detection details are load-bearing, and both come from a measurement that
* caught this file's own first draft:
*
* 1. SYNC VARIANTS COUNT. A regex for `writeFile(` does not match
* `writeFileSync(`, and `lib/backup.mjs` a real writer uses ONLY the
* sync forms. The first sweep of this defect scored it as a non-writer and
* was green on its own subject. [[guard-can-be-green-on-its-own-defect]]
*
* 2. COMMENTS ARE STRIPPED FIRST. The broad sweep returned 13 files; 4 were
* prose ("Coordinate naming across plugins, or rename one…", "removed or
* renamed in a newer version"). Requiring `(` kills those, but JSDoc that
* documents a signature (`write-output.mjs` line 28 spells
* `writeFile(path, contents, encoding)`) survives it, so comments go first.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCANNERS_DIR = fileURLToPath(new URL('../../scanners/', import.meta.url));
/**
* Files under `scanners/` that write to disk WITHOUT the scope gate, and why
* that is correct for each one.
*
* A rationale here is a claim about where the bytes land. It has to be true:
* "it felt like plugin bookkeeping" is how `scan-orchestrator.mjs` was carried
* as exempt in the plan text while `--save-baseline` wrote
* `resolve(targetPath, '.config-audit-baseline.json')` into the SCAN TARGET,
* so a `--global` run lands `~/.claude/.config-audit-baseline.json`, which is
* `user-scope` / `require-ok`. It is gated now rather than listed here.
*/
const EXEMPT = {
'lib/backup.mjs':
'Writes only into the plugin backup root (`~/.claude/config-audit/`, legacy '
+ '`~/.config-audit/`). Both are `plugin-managed`, so the gate classifies them '
+ '`silent` anyway; importing it here would add a second classification of the '
+ 'same path with no verdict to render. Backups are also the recovery artifact '
+ 'a gated write depends on — gating the backup would order the gate behind itself.',
'lib/baseline.mjs':
'Writes only under `~/.config-audit/baselines` (BASELINES_DIR, line 11), a '
+ '`plugin-managed` root. Distinct from `scan-orchestrator`\'s `--save-baseline`, '
+ 'which writes into the scan target and is therefore gated.',
'lib/campaign-ledger.mjs':
'Writes only `~/.claude/config-audit/campaign-ledger.json` (line 339), a fixed '
+ '`plugin-managed` path that cannot be redirected by a flag.',
'lib/write-output.mjs':
'Writes the scanner\'s own REPORT to the operator-named `--output-file`, never '
+ 'configuration. The path is arbitrary, so classifying it would raise '
+ '`require-ok` on any `--output-file` under `~/.claude` — on every run, for a '
+ 'file the operator just named on the command line. That is the "fires on every '
+ 'write and gets switched off" failure mode. A report is not a config change.',
};
/**
* Write calls, sync and async. `\b` before the name keeps `writeOutputFile(`
* from matching `writeFile(`; the trailing `(` keeps prose out.
*/
const WRITE_CALL_RE =
/\b(writeFile|writeFileSync|appendFile|appendFileSync|mkdir|mkdirSync|rename|renameSync|copyFile|copyFileSync|unlink|unlinkSync|rm|rmSync|createWriteStream)\s*\(/;
/** Files that import the scope gate are gated by construction. */
const GATE_IMPORT_RE = /from\s+['"][^'"]*write-scope\.mjs['"]/;
/**
* Remove block comments and whole-line comments. Deliberately does NOT touch
* `//` mid-line, so a `https://` inside a string cannot truncate real code.
*/
function stripComments(source) {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((line) => {
const t = line.trimStart();
return !t.startsWith('//') && !t.startsWith('*');
})
.join('\n');
}
function walkMjs(dir, acc = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) walkMjs(full, acc);
else if (entry.endsWith('.mjs')) acc.push(full);
}
return acc;
}
/** @returns {Array<{rel: string, gated: boolean, lines: number[]}>} */
function findWriters() {
const writers = [];
for (const full of walkMjs(SCANNERS_DIR)) {
const raw = readFileSync(full, 'utf-8');
const code = stripComments(raw);
if (!WRITE_CALL_RE.test(code)) continue;
const lines = [];
raw.split('\n').forEach((line, i) => {
const t = line.trimStart();
if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return;
if (WRITE_CALL_RE.test(line)) lines.push(i + 1);
});
writers.push({
rel: relative(SCANNERS_DIR, full),
gated: GATE_IMPORT_RE.test(raw),
lines,
});
}
return writers;
}
test('the sweep finds writers at all (an empty sweep certifies nothing)', () => {
const writers = findWriters();
assert.ok(
writers.length >= 8,
`expected the writer sweep to find at least 8 files, found ${writers.length}. `
+ 'A regex that stops matching makes every other assertion in this file '
+ 'vacuously green (#63, #64).',
);
});
test('every writer under scanners/ either imports the scope gate or is declared exempt', () => {
const ungated = findWriters()
.filter((w) => !w.gated && !(w.rel in EXEMPT))
.map((w) => `${w.rel} (writes at line ${w.lines.join(', ')})`);
assert.deepEqual(
ungated,
[],
'These files write to disk without importing `write-scope.mjs` and without an '
+ 'entry in EXEMPT. Either route the write through `classifyWriteTarget` + '
+ '`strongestGate`, or add an EXEMPT entry stating where the bytes land and '
+ 'why no verdict is owed:\n ' + ungated.join('\n '),
);
});
test('no EXEMPT entry is stale — each named file still writes, and is still ungated', () => {
const writers = new Map(findWriters().map((w) => [w.rel, w]));
for (const rel of Object.keys(EXEMPT)) {
const writer = writers.get(rel);
assert.ok(
writer,
`EXEMPT names \`${rel}\`, which no longer writes to disk. Remove the entry — a `
+ 'stale exemption is a standing permission nobody is using and nobody rechecks.',
);
assert.equal(
writer.gated,
false,
`EXEMPT names \`${rel}\`, but it now imports the scope gate. Remove the entry so `
+ 'the file is covered by the gate, not by a leftover exemption that outranks it.',
);
}
});
test('every EXEMPT rationale is substantive', () => {
for (const [rel, why] of Object.entries(EXEMPT)) {
assert.ok(
why.trim().length >= 80,
`EXEMPT['${rel}'] needs a rationale naming where the bytes land, not a label.`,
);
}
});

View file

@ -35,6 +35,11 @@ function world({ withSession = true, withPlan = true } = {}) {
const repoDir = join(root, 'repo');
mkdirSync(sessionsDir, { recursive: true });
mkdirSync(repoDir, { recursive: true });
// A repo in the ledger is a real repo. Without the marker the export target
// is in no project at all, which the Q1 scope gate classifies `require-ok`
// and withholds — so a fixture missing `.git` would be testing a case the
// ledger cannot produce, and would hide the by-design cross-repo path.
mkdirSync(join(repoDir, '.git'), { recursive: true });
let l = createLedger({ now: NOW });
l = addRepo(l, { path: repoDir, name: 'repo' }, { now: NOW });

View file

@ -68,10 +68,16 @@ describe('fix-cli --apply', () => {
});
it('applies fixes and creates backup', () => {
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json'], {
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json', '--repo', tmpDir], {
encoding: 'utf-8',
timeout: 30000,
env: hermeticEnv(),
// `--repo` above names the session root the Q1 gate classifies against,
// the same job `--repo "$PWD"` does in commands/fix.md. It is passed
// explicitly rather than via `cwd` because on macOS the tmp path is a
// symlink (/var -> /private/var) and `process.cwd()` reports the resolved
// form — root and target would then disagree as strings while naming one
// directory, and the gate would withhold an in-repo write.
});
const output = JSON.parse(result);
assert.ok(output.applied.length > 0, 'Should have applied fixes');
@ -88,7 +94,7 @@ describe('fix-cli --apply', () => {
});
it('actually modifies files after --apply', async () => {
execFileSync('node', [FIX_CLI, tmpDir, '--apply'], {
execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--repo', tmpDir], {
encoding: 'utf-8',
timeout: 30000,
env: hermeticEnv(),
@ -101,10 +107,16 @@ describe('fix-cli --apply', () => {
});
it('reports verified fixes', () => {
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json'], {
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json', '--repo', tmpDir], {
encoding: 'utf-8',
timeout: 30000,
env: hermeticEnv(),
// `--repo` above names the session root the Q1 gate classifies against,
// the same job `--repo "$PWD"` does in commands/fix.md. It is passed
// explicitly rather than via `cwd` because on macOS the tmp path is a
// symlink (/var -> /private/var) and `process.cwd()` reports the resolved
// form — root and target would then disagree as strings while naming one
// directory, and the gate would withhold an in-repo write.
});
const output = JSON.parse(result);
assert.ok(Array.isArray(output.verified), 'Should have verified array');
@ -185,7 +197,7 @@ describe('fix-cli exit codes (F8)', () => {
const rulesDir = join(dir, '.claude', 'rules');
mkdirSync(rulesDir, { recursive: true });
writeFileSync(join(rulesDir, 'both.txt'), '---\nglobs: "**/*.ts"\n---\n\nBody.\n');
const { status, stdout } = runCli([dir, '--apply', '--json']);
const { status, stdout } = runCli([dir, '--apply', '--json', '--repo', dir]);
const output = JSON.parse(stdout);
if (output.failed.length > 0) {
assert.strictEqual(status, 2, 'A failed fix must not be reported as exit 0');
@ -205,7 +217,7 @@ describe('fix-cli backup completeness (F4)', () => {
// file-rename is the only planned fix for this file.
writeFileSync(join(rulesDir, 'renameonly.txt'), '---\npaths: ["**/*.ts"]\n---\n\nBody.\n');
const { stdout } = runCli([dir, '--apply', '--json']);
const { stdout } = runCli([dir, '--apply', '--json', '--repo', dir]);
const output = JSON.parse(stdout);
const renames = output.applied.filter((a) => /renameonly\.txt$/.test(a.file));
assert.strictEqual(renames.length, 1, 'The rename must have been applied');

View file

@ -197,7 +197,7 @@ describe('applyFixes on tmp copy', () => {
it('applies json-key-add ($schema) successfully', async () => {
const { fixes } = planFixes(envelope);
const schemaFix = fixes.filter(f => f.type === FIX_TYPES.JSON_KEY_ADD);
const result = await applyFixes(schemaFix, { dryRun: false, backupDir: tmpDir });
const result = await applyFixes(schemaFix, { dryRun: false, backupDir: tmpDir, repoRoot: tmpDir });
assert.ok(result.applied.length > 0, 'Should apply at least one fix');
assert.strictEqual(result.failed.length, 0, 'No failures');
@ -212,7 +212,7 @@ describe('applyFixes on tmp copy', () => {
it('applies json-key-type-fix successfully', async () => {
const { fixes } = planFixes(envelope);
const typeFix = fixes.filter(f => f.type === FIX_TYPES.JSON_KEY_TYPE_FIX && f.key === 'alwaysThinkingEnabled');
const result = await applyFixes(typeFix, { dryRun: false, backupDir: tmpDir });
const result = await applyFixes(typeFix, { dryRun: false, backupDir: tmpDir, repoRoot: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
@ -223,7 +223,7 @@ describe('applyFixes on tmp copy', () => {
it('applies json-restructure (hooks array→object) successfully', async () => {
const { fixes } = planFixes(envelope);
const hooksFix = fixes.filter(f => f.restructureType === 'hooks-array-to-object');
const result = await applyFixes(hooksFix, { dryRun: false, backupDir: tmpDir });
const result = await applyFixes(hooksFix, { dryRun: false, backupDir: tmpDir, repoRoot: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
@ -235,7 +235,7 @@ describe('applyFixes on tmp copy', () => {
it('applies json-restructure (matcher object→string) successfully', async () => {
const { fixes } = planFixes(envelope);
const matcherFix = fixes.filter(f => f.restructureType === 'matcher-object-to-string');
const result = await applyFixes(matcherFix, { dryRun: false, backupDir: tmpDir });
const result = await applyFixes(matcherFix, { dryRun: false, backupDir: tmpDir, repoRoot: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, 'hooks', 'hooks.json'), 'utf-8');
@ -247,7 +247,7 @@ describe('applyFixes on tmp copy', () => {
it('applies frontmatter-rename (globs→paths) successfully', async () => {
const { fixes } = planFixes(envelope);
const fmFix = fixes.filter(f => f.type === FIX_TYPES.FRONTMATTER_RENAME);
const result = await applyFixes(fmFix, { dryRun: false, backupDir: tmpDir });
const result = await applyFixes(fmFix, { dryRun: false, backupDir: tmpDir, repoRoot: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, '.claude', 'rules', 'typescript.md'), 'utf-8');
@ -258,7 +258,7 @@ describe('applyFixes on tmp copy', () => {
it('applies file-rename (non-.md → .md) successfully', async () => {
const { fixes } = planFixes(envelope);
const renameFix = fixes.filter(f => f.type === FIX_TYPES.FILE_RENAME);
const result = await applyFixes(renameFix, { dryRun: false, backupDir: tmpDir });
const result = await applyFixes(renameFix, { dryRun: false, backupDir: tmpDir, repoRoot: tmpDir });
assert.ok(result.applied.length > 0);
// Old file should be gone
@ -282,7 +282,7 @@ describe('applyFixes on tmp copy', () => {
const { fixes } = planFixes(env);
const effortFix = fixes.filter(f => f.key === 'effortLevel');
assert.strictEqual(effortFix.length, 1, `"${raw}" must be planned as exactly one effortLevel fix`);
await applyFixes(effortFix, { dryRun: false, backupDir: dir });
await applyFixes(effortFix, { dryRun: false, backupDir: dir, repoRoot: dir });
return parseJson(await readFile(settingsPath, 'utf-8')).effortLevel;
}
@ -313,7 +313,7 @@ describe('applyFixes on tmp copy', () => {
it('validates JSON output after fix', async () => {
const { fixes } = planFixes(envelope);
const jsonFixes = fixes.filter(f => f.file.endsWith('.json'));
await applyFixes(jsonFixes, { dryRun: false, backupDir: tmpDir });
await applyFixes(jsonFixes, { dryRun: false, backupDir: tmpDir, repoRoot: tmpDir });
// All JSON files should still parse
const settingsContent = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
@ -335,7 +335,7 @@ describe('applyFixes on tmp copy', () => {
key: 'test',
value: true,
}];
const result = await applyFixes(fakeFix, { dryRun: false, backupDir: tmpDir });
const result = await applyFixes(fakeFix, { dryRun: false, backupDir: tmpDir, repoRoot: tmpDir });
assert.strictEqual(result.failed.length, 1, 'Should have one failure');
assert.strictEqual(result.applied.length, 0);
});
@ -360,7 +360,7 @@ describe('verifyFixes', () => {
// Apply a subset of fixes
const fmFix = fixes.filter(f => f.type === FIX_TYPES.FRONTMATTER_RENAME);
const result = await applyFixes(fmFix, { dryRun: false, backupDir: tmpDir });
const result = await applyFixes(fmFix, { dryRun: false, backupDir: tmpDir, repoRoot: tmpDir });
const verification = await verifyFixes(envelope, result.applied);
assert.ok(verification.verified.length > 0, 'Should verify at least one fix');
@ -391,7 +391,7 @@ describe('planFixes ordering (M-BUG-29)', () => {
// …and the whole batch must therefore apply cleanly.
const backupDir = join(dir, '.backup-test');
mkdirSync(backupDir, { recursive: true });
const result = await applyFixes(fixes, { dryRun: false, backupDir });
const result = await applyFixes(fixes, { dryRun: false, backupDir, repoRoot: dir });
assert.deepStrictEqual(result.failed, [], 'No fix may fail because of ordering');
await rm(dir, { recursive: true, force: true });

View file

@ -65,7 +65,7 @@ describe('fix verification distinguishes two instances of one check', () => {
const backupDir = join(dir, '.backups');
await mkdir(backupDir, { recursive: true });
const { applied } = await applyFixes(alphaFix, { backupDir });
const { applied } = await applyFixes(alphaFix, { backupDir, repoRoot: dir });
assert.equal(applied.length, 1);
const { verified, regressions } = await verifyFixes(envelope, applied);

View file

@ -76,7 +76,7 @@ describe('restoreBackup', () => {
});
it('restores files to original content', async () => {
const result = await restoreBackup(backup.backupId);
const result = await restoreBackup(backup.backupId, { repoRoot: tmpDir });
assert.ok(result.restored.length > 0, 'Should restore at least one file');
assert.strictEqual(result.failed.length, 0, 'No failures');
@ -85,14 +85,14 @@ describe('restoreBackup', () => {
});
it('verifies checksums after restore', async () => {
const result = await restoreBackup(backup.backupId, { verify: true });
const result = await restoreBackup(backup.backupId, { verify: true, repoRoot: tmpDir });
for (const r of result.restored) {
assert.strictEqual(r.status, 'restored');
}
});
it('dry-run returns plan without writing', async () => {
const result = await restoreBackup(backup.backupId, { dryRun: true });
const result = await restoreBackup(backup.backupId, { dryRun: true, repoRoot: tmpDir });
assert.ok(result.restored.length > 0);
for (const r of result.restored) {
assert.strictEqual(r.status, 'dry-run');

View file

@ -143,7 +143,7 @@ describe('listBackups / restoreBackup across both roots', () => {
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
writeFileSync(target, '{"modified": true}');
const result = await restoreBackup(backupId);
const result = await restoreBackup(backupId, { repoRoot: work });
assert.strictEqual(result.failed.length, 0, 'no failures expected');
assert.strictEqual(result.restored.length, 1, 'the legacy backup should resolve');