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

@ -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');