feat(commands): a write that leaves the repo says so before you approve it (M-BUG-41)

The chain observed configuration across repos but presented every write it then
proposed as though it landed where the session stands. STATE named two arms;
measuring found five, and two of them are worse than the two already known:

- implement — the approval prompt named NO path at all, only a count, so a plan
  editing ~/.claude/CLAUDE.md and one editing ./CLAUDE.md produced byte-identical
  prompts.
- rollback — the file list rendered `.claude/settings.json`, a repo-relative
  FORM, while the restore writes to the absolute original. The other arms were
  silent; this one pointed the wrong way.
- fix — paths were visible but unclassified, and --global mixed machine-wide and
  project rows into one unmarked table.

The gate's strength comes from the target's scope class, never from the command
asking: five command-owned policies would drift apart the way five copies of the
lever table did. SCOPE_CLASSES is one source for class, gate, wording and
predicate; templates render `disclosures[]` from the CLI instead of restating
what a class means.

Two orderings in that table are load-bearing, and both were measured:

- plugin-managed before user-scope. Both ~/.claude/config-audit/ and the legacy
  ~/.config-audit/ are live, and every command writes session state there. The
  other order fires the gate on every write ever made and gets it switched off,
  which is worse than no gate.
- user-scope before cross-repo. ~/.claude/.git EXISTS, so a plain .git-upward
  walk answers "another repo" for ~/.claude/CLAUDE.md and silently downgrades
  the strongest gate on the subtraction axis's primary target to disclosure.

disclose is not require-ok: campaign export is cross-repo by design, so the gate
there says so rather than refusing. Distinct from require-target-dir.mjs, which
asks whether a scan ROOT is readable (exit 3) — a different invariant, left
unmerged along with its four inline copies.

Also structural, both found while building this: the hand-maintained GUARDED
list in the unknown-flag sweep now derives its completeness from the directory
(measured complete at 14 of 14 first, so nothing was hiding — but the 15th CLI
would have been swept by nothing); and prose shape-guards use whitespace-
tolerant patterns, after one went red against a command file that did say the
right thing, line-wrapped.

Gated: implement, fix, rollback, plan, campaign export. Suite 1596 -> 1625/0,
frozen v5.0.0 and default-output baselines 0 changed files. No new GAP dimension,
no lever, no finding code — utilization denominators untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013941cEohSD5Aw56FVAtBgZ
This commit is contained in:
Kjell Tore Guttormsen 2026-08-10 05:40:12 +02:00
commit 1543830c52
12 changed files with 821 additions and 9 deletions

View file

@ -93,6 +93,8 @@ Enforced conventions live in `.claude/rules/` (auto-loaded as project instructio
Coding style: scanners are zero-dependency Node ESM; new findings use the `CA-{SCANNER}-{NNN}` ID format; byte-stable CLIs are verified against frozen `tests/snapshots/v5.0.0/` baselines.
**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.
**Subtraction floor (invariant).** `optimize --subtract` is the only lens that proposes removing config, so `scanners/lib/floor-exclusion.mjs` runs as a deterministic pre-step *before* the judge — a load-bearing block is never a candidate, and that guarantee must not be moved into the agent prompt. Two rules follow from it: (1) **staleness is not a deletion signal** — an outdated version pin inside a floor block is a `drift`/`CA-CML` dead-reference concern; (2) **tier 2 ≠ tier 3** — a compensatory block that keeps earning its place returns, and reporting it as dead weight is wrong even when the label matches. Norwegian keywords need the Unicode boundaries in `subtraction-prefilter.mjs`; JS `\b` is ASCII-only, so `/\bunngå\b/` silently never matches.
## Testing

View file

@ -550,6 +550,7 @@ Shared modules used by all scanners — useful if you're reading the source or e
| `suppression.mjs` | `.config-audit-ignore` parsing, finding suppression, audit trail |
| `active-config-reader.mjs` | Read-only inventory of plugins/skills/MCP/hooks/CLAUDE.md cascade with token estimates |
| `tokenizer-api.mjs` | Anthropic `count_tokens` wrapper for `--accurate-tokens` (v5 N5); 5s timeout, 429 backoff, key masking |
| `write-scope.mjs` | Classifies a write target against the current project (`SCOPE_CLASSES`, `classifyWriteTarget()`); one source for class, gate strength and wording |
### Action Engines
@ -562,6 +563,7 @@ Shared modules used by all scanners — useful if you're reading the source or e
| `manifest.mjs` | CLI: ranked component-level source table w/ load-pattern accounting (v5 N2; v5.6 B) |
| `whats-active.mjs` | CLI: read-only active-config inventory (v3.1.0+) |
| `token-hotspots-cli.mjs` | CLI: token hotspots ranking with optional `--accurate-tokens` |
| `write-scope-cli.mjs` | CLI: classify write targets before an approval surface (`--target`, repeatable) |
---
@ -665,6 +667,28 @@ This plugin is cautious by design — configuration files are important, and a b
| **Verification pass** | A separate agent confirms changes actually work |
| **Human-in-the-loop** | You approve the plan before anything is implemented |
| **Post-edit guard** | Hook blocks the session if a new critical/high finding is introduced |
| **Scope disclosed before every write** | Each write target is classified against the project you are in, and the approval surface says when a change leaves it |
### Writes That Leave Your Project
config-audit reads configuration across projects and machine-wide, so some of what
it proposes does not land where you are standing. A count of files cannot tell those
cases apart: a plan that edits `~/.claude/CLAUDE.md` and one that edits your
project's own `CLAUDE.md` are both "1 file".
Every write target is therefore classified before you are asked to approve it, and
the class — not the command — decides how strong the gate is:
| Where the write lands | What happens |
|---|---|
| Inside the project you are working in | No extra gate; the usual confirmation applies |
| config-audit's own session state and backups | No extra gate; this is the plugin's bookkeeping, not your configuration |
| Your machine-wide Claude configuration (`~/.claude`) | Stated plainly, and it needs an explicit go-ahead — a change here affects every project you open |
| A different project | Stated plainly, including that directories will be created there. `campaign export` does this deliberately, so this is disclosure, not refusal |
| Anywhere else | Stated plainly, and it needs an explicit go-ahead |
Where a machine-wide or cross-project write is involved, the safe option is listed
first — the default is never "proceed".
---

View file

@ -275,6 +275,18 @@ no/corrupt ledger). Read `~/.claude/config-audit/sessions/campaign-export.json`
— the first ~12 lines of `document` only, never the whole file, never the raw JSON (UX rules).
Ask for explicit approval to write it.
Showing the path is not the same as saying it leaves this repo. Classify it first:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<targetPath>" --repo "$PWD" --output-file ~/.claude/config-audit/sessions/campaign-export-scope.json 2>/dev/null; echo $?
```
Read that file and render each distinct string in `disclosures[]` verbatim before the
approval question. Exporting into another repo is what this command is *for*, so the
gate here **discloses and does not refuse** — say that the write lands in a different
project and that a `docs/` directory will be created there if it is missing. Do not
turn this into a refusal.
**On approval, write it** (the CLI does the faithful copy — do NOT hand-write the file):
```bash

View file

@ -65,6 +65,19 @@ Read `/tmp/config-audit-fix-plan.json` using the Read tool. Cross-reference each
### 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:
```bash
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:
```markdown
@ -76,9 +89,9 @@ Show what will be fixed and what needs manual attention. Group by `userActionLan
#### {userActionLanguage}
| # | ID | Issue | File |
|---|-----|-------|------|
| 1 | {id} | {humanized title} | {file} |
| # | ID | Issue | File | Scope |
|---|-----|-------|------|-------|
| 1 | {id} | {humanized title} | {file} | {scopeClass, or blank when "in-repo"} |
**Manual ({M} issues — require human judgment), grouped by impact:**
@ -91,7 +104,10 @@ Show what will be fixed and what needs manual attention. Group by `userActionLan
### Step 4: Confirm with user
If not `--dry-run`, ask for confirmation:
If not `--dry-run`, ask for confirmation. Render each distinct string in the scope
payload's `disclosures[]` verbatim first.
When `requiresApproval` is false:
```
AskUserQuestion:
@ -102,6 +118,19 @@ AskUserQuestion:
- "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:

View file

@ -30,17 +30,39 @@ Find the most recent session with a plan (use the **Glob tool** for
`~/.claude/config-audit/sessions/*/state.yaml`, then Read the newest match — Read
does not expand `*`). If none: "No action plan found. Run `/config-audit plan` first."
Use the Read tool on the action plan and count actions. Tell the user:
Use the Read tool on the action plan and count actions.
Now classify where those actions actually write. A plan whose actions target
`~/.claude/CLAUDE.md` and a plan whose actions target `./CLAUDE.md` are the same
count of actions — presenting only the count made a machine-wide change look
identical to a project-local one. Pass one `--target` per distinct file the plan
touches (absolute paths, as written in the plan):
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<file-1>" --target "<file-2>" --repo "$PWD" --output-file /tmp/config-audit-implement-scope.json 2>/dev/null; echo $?
```
Exit 0 = classified; 3 = argument error (show the stderr message). Read
`/tmp/config-audit-implement-scope.json`. Tell the user:
```
## Implementing Action Plan
Found {N} actions to execute across {M} files.
A backup will be created before any changes are made.
{For each target whose `gate` is not "silent", one line:}
- `{target}` — {scopeClass}
```
### Step 2: Get user approval
Render each distinct string in `disclosures[]` verbatim before asking — they are
already plain-language, and the payload carries them so this template never has
to restate what a scope class means.
When `requiresApproval` is false, ask as before:
```
AskUserQuestion:
question: "Ready to implement {N} actions? Backup created automatically — you can roll back with one command."
@ -50,6 +72,19 @@ AskUserQuestion:
- "Cancel"
```
When `requiresApproval` is true, the question MUST name the scope, and the
safe option MUST come first — a plan that edits machine-wide configuration
affects every project the user opens, so the default must not be "proceed":
```
AskUserQuestion:
question: "This plan changes configuration outside this project ({K} of {M} files). Proceed?"
options:
- "Review plan first" (then show the plan file path)
- "Yes — change files outside this project too"
- "Cancel"
```
### Step 3: Create backup
Create backup silently, and **print the backup ID** — Step 6 has to tell the user

View file

@ -73,7 +73,22 @@ Agent(subagent_type: "config-audit:planner-agent")
### Step 4: Present the plan summary
Read the generated plan and show a concise overview:
Read the generated plan, then classify the files its actions target. This summary
IS the approval surface — there is no separate confirmation step here, so a plan
that proposes writing to machine-wide configuration has to say so where the user
reads it. Pass one `--target` per distinct file the plan touches:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<file-1>" --target "<file-2>" --repo "$PWD" --output-file /tmp/config-audit-plan-scope.json 2>/dev/null; echo $?
```
Exit 0 = classified; 3 = argument error (show the stderr message). Read
`/tmp/config-audit-plan-scope.json`. If `gate` is not `"silent"`, render each
distinct string in `disclosures[]` verbatim directly under the action table, and
mark the affected rows — not in a footnote further down, where a user scanning the
table would miss it.
Show a concise overview:
```markdown
### Action Plan Ready

View file

@ -45,7 +45,21 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
### Restore mode (with backup ID)
1. Read the list of changes from `~/.claude/config-audit/backups/{backup-id}/manifest.yaml` using the Read tool
2. Show files that will be restored — ask for confirmation:
2. Classify the `original:` paths 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:
```bash
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?"
@ -53,6 +67,15 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
- "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"
```
3. For each file in the list of changes:
a. Read the backup file from `~/.claude/config-audit/backups/{backup-id}/files/{safeName}`
b. Write to the original path
@ -60,8 +83,8 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
4. Show result:
```
Restored 3 files from backup 20260403_163045
- .claude/settings.json (checksum verified)
- hooks/hooks.json (checksum verified)
- /abs/path/.claude/settings.json (checksum verified)
- /abs/path/hooks/hooks.json (checksum verified)
- .claude/rules/typescript.md (checksum verified)
```
5. **Report what rollback cannot undo.** A backup only holds files that already

View file

@ -0,0 +1,183 @@
/**
* Write-target scope classification (M-BUG-41).
*
* The workflow observes configuration across repos, but every write it then
* proposes was presented as though it landed where the session stands. Five
* arms were measured carrying that hole: `implement` (approval prompt names no
* path at all only a count), `rollback` (renders repo-relative-looking paths
* while writing to absolute originals), `fix` (`--global` mixes user-scope and
* repo rows into one unmarked table), `plan`, and `campaign export`.
*
* The gate's STRENGTH comes from the target's scope class, never from which
* command is asking. Command-owned policy would be five policies to drift apart
* the shape that put the lever table in five copies (#61). Both required
* outcomes then fall out of one table without an exception rule: a plan
* exported into another repo is *disclosed* (cross-repo is by design there),
* while a rewrite of `~/.claude/CLAUDE.md` *requires explicit approval*,
* because it costs in every repo on every turn.
*
* `silent` means "no gate of its own", not "no approval": the existing
* confirmation surfaces stand untouched, and this module only adds location to
* them.
*
* Two orderings below are load-bearing, and both were measured rather than
* reasoned about:
*
* `plugin-managed` before `user-scope` the canonical
* `~/.claude/config-audit/` and the legacy `~/.config-audit/` both exist on a
* real machine, and every command writes session state into them. Matched the
* other way round, the gate fires on every write ever made and gets switched
* off, which is worse than having no gate.
*
* `user-scope` before `cross-repo` `~/.claude/.git` exists (the operator's
* `~/.claude` is a git repo whose `.gitignore` is `*`). A plain
* `.git`-upward-walk therefore answers "another repo" for
* `~/.claude/CLAUDE.md`, silently downgrading the strongest gate on the
* subtraction axis's primary target to disclosure-only.
*
* This module classifies. It never writes, never prompts, and never decides
* whether an approved write is a good idea.
*/
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join, relative, resolve } from 'node:path';
/**
* True when `child` is `parent` itself or lives underneath it.
*
* Uses `relative()` rather than `startsWith()`: a sibling directory whose name
* merely prefixes the parent's (`my-plugin-2` against `my-plugin`) satisfies
* `startsWith` and would skip the gate entirely.
*
* @param {string} parent - Absolute directory path.
* @param {string} child - Absolute path to test.
* @returns {boolean}
*/
function isWithin(parent, child) {
const rel = relative(parent, child);
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
}
/**
* Default repo-root test. Kept injectable so classification is testable
* without a fixture tree.
*
* @param {string} dir - Absolute directory path.
* @returns {boolean}
*/
function defaultIsRepoRoot(dir) {
return existsSync(join(dir, '.git'));
}
/**
* Walk upwards from `absPath` looking for the nearest enclosing repo root.
*
* @param {string} absPath - Absolute path to start from.
* @param {(dir: string) => boolean} isRepoRoot - Repo-root predicate.
* @returns {string|null} The nearest repo root, or null if there is none.
*/
function nearestRepoRoot(absPath, isRepoRoot) {
let dir = absPath;
for (;;) {
if (isRepoRoot(dir)) return dir;
const parent = resolve(dir, '..');
if (parent === dir) return null;
dir = parent;
}
}
/**
* The scope classes, in match order.
*
* Declaration order IS match order this object is the single source for the
* class name, its gate, its disclosure wording and its predicate, so no caller
* and no test can hold a second copy that drifts.
*
* @type {Record<string, {gate: 'silent'|'disclose'|'require-ok', disclosure: string|null, matches: Function}>}
*/
export const SCOPE_CLASSES = {
// The plugin's own bookkeeping: session state, backups, ledgers. Not the
// user's configuration, and written on essentially every run.
'plugin-managed': {
gate: 'silent',
disclosure: null,
matches: (target, ctx) => ctx.pluginRoots.some((root) => isWithin(root, target)),
},
// Where the session stands. The ordinary case.
'in-repo': {
gate: 'silent',
disclosure: null,
matches: (target, ctx) => ctx.repoRoot !== null && isWithin(ctx.repoRoot, target),
},
// Machine-wide configuration: loaded in every repo, on every turn, so the
// cost of a change here is not confined to the project in front of the user.
'user-scope': {
gate: 'require-ok',
disclosure: 'This writes to your machine-wide Claude configuration, outside this project. '
+ 'It affects every project you open, so it needs your explicit go-ahead.',
matches: (target, ctx) => isWithin(ctx.userConfigRoot, target),
},
// A different project. Some commands do this by design; the gate is to say
// so, not to refuse.
'cross-repo': {
gate: 'disclose',
disclosure: 'This writes into a different project than the one you are working in. '
+ 'Any directories it needs there will be created.',
matches: (target, ctx) => {
const root = nearestRepoRoot(target, ctx.isRepoRoot);
return root !== null && root !== ctx.repoRoot;
},
},
// Neither this project, nor another project, nor machine-wide config.
'outside': {
gate: 'require-ok',
disclosure: 'This writes to a location outside any project and outside your Claude '
+ 'configuration, so it needs your explicit go-ahead.',
matches: () => true,
},
};
/**
* Classify a write target relative to the repo the session stands in.
*
* @param {string} targetPath - The path that is about to be written.
* @param {string|null} sessionRepoRoot - Repo root of the current session.
* @param {object} [options]
* @param {(dir: string) => boolean} [options.isRepoRoot] - Repo-root predicate.
* @param {string} [options.home] - Override for the home directory.
* @returns {{scopeClass: string, gate: string, disclosure: string|null, target: string}}
*/
export function classifyWriteTarget(targetPath, sessionRepoRoot, options = {}) {
const home = options.home ?? homedir();
const isRepoRoot = options.isRepoRoot ?? defaultIsRepoRoot;
const target = resolve(targetPath);
const ctx = {
repoRoot: sessionRepoRoot === null || sessionRepoRoot === undefined
? null
: resolve(sessionRepoRoot),
userConfigRoot: join(home, '.claude'),
// Both roots are live: `backup.mjs` prefers `~/.claude/config-audit/` and
// falls back to the legacy `~/.config-audit/`.
pluginRoots: [
join(home, '.claude', 'config-audit'),
join(home, '.config-audit'),
],
isRepoRoot,
};
for (const [scopeClass, spec] of Object.entries(SCOPE_CLASSES)) {
if (spec.matches(target, ctx)) {
return { scopeClass, gate: spec.gate, disclosure: spec.disclosure, target };
}
}
// Unreachable: `outside` matches unconditionally. Kept so a future edit that
// narrows the last predicate fails loudly instead of returning undefined.
throw new Error(`write-scope: no class matched ${target}`);
}

View file

@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* write-scope CLI classify the write targets a command is about to touch,
* relative to the repo the session stands in (M-BUG-41).
*
* Exists so the gate has ONE implementation. Five command templates need the
* same answer before their approval surface; five prose paraphrases of the
* class table would be five policies drifting apart the shape that put the
* lever table in five copies (#61). The templates call this and render what
* comes back.
*
* Usage:
* node write-scope-cli.mjs --target <path> [--target <path> ...]
* [--repo <session-repo-root>]
* [--output-file <path>] [--json]
*
* Exit codes: 0 = classified, 3 = argument or tool error.
*
* A gated target is NOT an error exit. The exit-code contract reserves 3 for
* "the scanner could not do its job"; "this write leaves the repo" is a verdict
* about a write, and it rides in the payload a command cannot act on
* something that only ever reached stderr (F3's class).
*
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
import { SCOPE_CLASSES, classifyWriteTarget } from './lib/write-scope.mjs';
/** Flag surface. Anything else is exit 3. */
const ARG_SPEC = { boolean: ['--json'], value: ['--target', '--repo', '--output-file'] };
/** Gate strengths, weakest first. The strongest one present drives the surface. */
const GATE_RANK = ['silent', 'disclose', 'require-ok'];
/**
* Pick the strongest gate among the classified targets.
*
* @param {Array<{gate: string}>} targets
* @returns {string} The strongest gate, or 'silent' when there are no targets.
*/
function strongestGate(targets) {
let worst = 'silent';
for (const t of targets) {
if (GATE_RANK.indexOf(t.gate) > GATE_RANK.indexOf(worst)) worst = t.gate;
}
return worst;
}
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
const targets = [];
let repo = process.cwd();
let outputFile = null;
let jsonMode = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--json') jsonMode = true;
else if (args[i] === '--target') targets.push(args[++i]);
else if (args[i] === '--repo') repo = args[++i];
else if (args[i] === '--output-file') outputFile = args[++i];
}
if (targets.length === 0) {
process.stderr.write('Error: at least one --target is required\n');
process.exitCode = 3;
return;
}
const classified = targets.map((t) => classifyWriteTarget(t, repo));
const gate = strongestGate(classified);
const payload = {
meta: {
repo: resolve(repo),
targetCount: classified.length,
// The class table travels with the answer so a template never has to
// restate what a class means.
classes: Object.fromEntries(
Object.entries(SCOPE_CLASSES).map(([name, spec]) => [name, { gate: spec.gate }]),
),
},
gate,
requiresApproval: gate === 'require-ok',
// Distinct disclosure lines, in class order, ready to render verbatim.
disclosures: [...new Set(classified.map((t) => t.disclosure).filter(Boolean))],
targets: classified,
};
const json = `${JSON.stringify(payload, null, 2)}\n`;
if (outputFile) {
await writeOutputFile(outputFile, json);
// Nothing on stdout when writing to a file: a command that also renders
// this would otherwise show the user the raw payload (ux-rules rule 1).
} else if (jsonMode) {
process.stdout.write(json);
} else {
for (const t of payload.targets) {
process.stdout.write(`${t.scopeClass}\t${t.gate}\t${t.target}\n`);
}
}
}
try {
await main();
} catch (err) {
process.stderr.write(`Error: ${err.message}\n`);
process.exitCode = 3;
}

View file

@ -0,0 +1,160 @@
/**
* M-BUG-41 scope-gate wiring across the write surface.
*
* The chain lacked a gate between "observation across repos" and "write action
* outside the repo the session stands in". Five arms were measured carrying it
* (`docs/mbug41-scope-gate-fasit.local.md` §2). STATE named two of them; the
* three found by measuring were `implement`, `fix` and `rollback`, and two are
* worse than the two originally known:
*
* - `implement` the approval prompt named NO path at all, only a count, so a
* plan editing `~/.claude/CLAUDE.md` and one editing `./CLAUDE.md` produced
* byte-identical prompts.
* - `rollback` the file list rendered `.claude/settings.json`, a
* repo-relative FORM, while the restore writes to the absolute original.
* The other arms were silent; this one pointed the wrong way.
* - `fix` paths were visible, but unclassified, and `--global` mixed
* machine-wide and project rows into one unmarked table.
*
* A CLI carries the answer instead of each template restating the class table,
* because five prose paraphrases of one policy are five policies drifting apart
* the shape that put the lever table in five copies (#61).
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile, readdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
const SCANNERS_DIR = resolve(__dirname, '..', '..', 'scanners');
/**
* Every command whose approval surface precedes a write that can leave the repo.
* `gatesApproval` marks the ones that own an `AskUserQuestion` and must
* therefore branch on `requiresApproval`; `plan` has no confirmation step at all
* its summary IS the approval surface and `campaign` writes cross-repo by
* design, where the gate discloses rather than blocks.
*/
const GATED_COMMANDS = [
{ file: 'implement.md', gatesApproval: true },
{ file: 'fix.md', gatesApproval: true },
{ file: 'rollback.md', gatesApproval: true },
{ file: 'plan.md', gatesApproval: false },
{ file: 'campaign.md', gatesApproval: false },
];
async function readCommand(name) {
return await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
}
for (const { file, gatesApproval } of GATED_COMMANDS) {
test(`${file} classifies its write targets before the approval surface`, async () => {
const content = await readCommand(file);
assert.match(
content,
/write-scope-cli\.mjs/,
`${file} presents a write without classifying where it lands. Showing a count — or even a\n` +
'path — does not tell the user the write leaves this project.',
);
assert.match(
content,
/\$\{CLAUDE_PLUGIN_ROOT\}\/scanners\/write-scope-cli\.mjs/,
`${file} must anchor the CLI at \${CLAUDE_PLUGIN_ROOT} — a relative path resolves against\n` +
"the user's working directory, not the plugin.",
);
assert.match(
content,
/write-scope-cli\.mjs[^\n]*--output-file[^\n]*2>\/dev\/null/,
`${file} must invoke the CLI as \`--output-file <path> 2>/dev/null\` (ux-rules rule 2), or\n` +
'the user sees raw payload and diagnostics.',
);
assert.match(
content,
/disclosures/,
`${file} must render the payload's \`disclosures[]\`. Wording kept in the payload stays in\n` +
'one place; wording paraphrased per command is a policy copy that drifts.',
);
});
if (gatesApproval) {
test(`${file} branches its approval question on requiresApproval`, async () => {
const content = await readCommand(file);
assert.match(
content,
/requiresApproval/,
`${file} owns an AskUserQuestion before a write, so it must branch on\n` +
'`requiresApproval` — a machine-wide change must not share a prompt with a local one.',
);
assert.match(
content,
/outside\s+this\s+project/,
`${file} must say the write leaves this project, in words. The scope class name alone\n` +
'is vocabulary the user has not been taught.',
);
});
}
}
test('rollback shows absolute originals, not a repo-relative-looking form', async () => {
const content = await readCommand('rollback.md');
// The measured defect: the result list rendered `- .claude/settings.json`,
// which reads as project-local while the restore targets an absolute path.
assert.doesNotMatch(
content,
/^\s*-\s+\.claude\/settings\.json/m,
'rollback renders a restore target in repo-relative form. The restore writes to the absolute\n' +
'original recorded at backup time, which may be machine-wide — the display must not imply\n' +
'otherwise.',
);
});
test('campaign discloses the cross-repo write and does not refuse it', async () => {
const content = await readCommand('campaign.md');
assert.match(
content,
/different\s+project/,
'campaign export writes into another repo by design, so the gate has to SAY so.',
);
assert.match(
content,
/does\s+not\s+refuse|not\s+turn\s+this\s+into\s+a\s+refusal/,
'The template must record that this arm discloses rather than blocks — a later edit that\n' +
'"tightens" it into a refusal would break a by-design feature (fasit §6 A1).',
);
});
test('the scope-class table lives in exactly ONE module', async () => {
const entries = await readdir(SCANNERS_DIR, { recursive: true });
const owners = [];
for (const name of entries) {
if (!name.endsWith('.mjs')) continue;
const src = await readFile(resolve(SCANNERS_DIR, name), 'utf-8');
if (src.includes('plugin-managed')) owners.push(name);
}
assert.deepEqual(
owners,
['lib/write-scope.mjs'],
'A second module naming the scope classes is a second copy of the policy. The class, its\n' +
'gate, its disclosure wording and its predicate belong in one export (#61: the lever table\n' +
'was found in five copies).',
);
});
test('no command file restates the class table in prose', async () => {
for (const { file } of GATED_COMMANDS) {
const content = await readCommand(file);
assert.doesNotMatch(
content,
/plugin-managed/,
`${file} names an internal scope class. Templates render what the payload returns; a\n` +
'template that enumerates the classes is a policy copy in prose.',
);
}
});

View file

@ -0,0 +1,185 @@
/**
* M-BUG-41 write-scope classification tests.
*
* The chain lacked a gate between "observation across repos" and "write action
* outside the repo the session stands in". Five arms were measured carrying the
* same hole (`docs/mbug41-scope-gate-fasit.local.md` §2): `implement` (approval
* prompt names no path at all), `rollback` (renders repo-relative-looking paths
* while writing to absolute originals), `fix` (`--global` mixes user-scope and
* repo rows in one unmarked table), `plan`, and `campaign export`.
*
* The design decision the fasit settled: the gate's STRENGTH comes from the
* target's scope CLASS, never from which command is asking five command-owned
* policies would drift apart the way five copies of the lever table did (#61).
*
* Two ordering rules in `SCOPE_CLASSES` are load-bearing, both measured:
*
* 1. `plugin-managed` BEFORE `user-scope`. Both `~/.claude/config-audit/` and
* the legacy `~/.config-audit/` exist on the operator's machine, and every
* command writes session state there. A naive "under ~/.claude require
* approval" rule would fire on every single write and be switched off.
*
* 2. `user-scope` BEFORE `cross-repo`. Measured: `~/.claude/.git` EXISTS
* the operator's `~/.claude` is a git repo with a `.gitignore` of `*`. So a
* naive `.git`-upward-walk classifies `~/.claude/CLAUDE.md` the single
* most important subtraction-write target as merely cross-repo
* ("disclose") when it must be user-scope ("require explicit OK").
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { join } from 'node:path';
import { homedir } from 'node:os';
import {
SCOPE_CLASSES,
classifyWriteTarget,
} from '../../scanners/lib/write-scope.mjs';
const HOME = homedir();
const REPO = '/Users/example/repos/my-plugin';
// A repo-root predicate is injected so classification stays hermetic: the tests
// never touch the filesystem, and `~/.claude` is declared a repo root here
// precisely because it IS one on the real machine.
function fakeIsRepoRoot(dir) {
return dir === REPO
|| dir === '/Users/example/repos/other-plugin'
|| dir === join(HOME, '.claude');
}
function classify(target) {
return classifyWriteTarget(target, REPO, { isRepoRoot: fakeIsRepoRoot });
}
test('SCOPE_CLASSES carries class, gate and disclosure in ONE export', () => {
const names = Object.keys(SCOPE_CLASSES);
assert.deepEqual(
names,
['plugin-managed', 'in-repo', 'user-scope', 'cross-repo', 'outside'],
'declaration order IS the match order — reordering changes behaviour',
);
for (const [name, spec] of Object.entries(SCOPE_CLASSES)) {
assert.ok(
['silent', 'disclose', 'require-ok'].includes(spec.gate),
`${name} must carry a known gate, got ${spec.gate}`,
);
if (spec.gate === 'silent') {
assert.equal(spec.disclosure, null, `${name} is silent, so it has no disclosure text`);
} else {
assert.ok(
typeof spec.disclosure === 'string' && spec.disclosure.length > 0,
`${name} gates the write, so it MUST carry disclosure text in this same export`,
);
}
}
});
// ---------------------------------------------------------------------------
// §5 taus-liste — four DIFFERENT reasons for silence, four fixtures. One
// "nothing fired" assertion would pass on any of them (C4's P5 lesson).
// ---------------------------------------------------------------------------
test('T1 — plugin bookkeeping under the canonical root is silent', () => {
const r = classify(join(HOME, '.claude', 'config-audit', 'sessions', 'x', 'state.yaml'));
assert.equal(r.scopeClass, 'plugin-managed');
assert.equal(r.gate, 'silent');
});
test('T2 — plugin bookkeeping under the LEGACY root is silent for the same reason', () => {
const r = classify(join(HOME, '.config-audit', 'backups', 'x', 'manifest.yaml'));
assert.equal(r.scopeClass, 'plugin-managed');
assert.equal(r.gate, 'silent');
});
test('T3 — a target inside the session repo is silent', () => {
const r = classify(join(REPO, 'CLAUDE.md'));
assert.equal(r.scopeClass, 'in-repo');
assert.equal(r.gate, 'silent');
});
test('T4 — the repo root itself counts as in-repo, not as "outside"', () => {
const r = classify(REPO);
assert.equal(r.scopeClass, 'in-repo');
assert.equal(r.gate, 'silent');
});
// ---------------------------------------------------------------------------
// The gated classes
// ---------------------------------------------------------------------------
test('user-scope config requires explicit approval, not mere disclosure', () => {
const r = classify(join(HOME, '.claude', 'CLAUDE.md'));
assert.equal(r.scopeClass, 'user-scope');
assert.equal(r.gate, 'require-ok');
assert.ok(r.disclosure.length > 0);
});
test('ORDERING — ~/.claude is a real git repo, and must STILL classify as user-scope', () => {
// The regression this pins: a `.git`-upward walk sees ~/.claude/.git (it
// exists on the operator's machine) and would answer "cross-repo" =>
// disclose-only, silently downgrading the gate on the subtraction axis's
// primary target.
const r = classify(join(HOME, '.claude', 'skills', 'some-skill', 'SKILL.md'));
assert.equal(r.scopeClass, 'user-scope', 'user-scope must be matched BEFORE cross-repo');
assert.equal(r.gate, 'require-ok');
});
test('ORDERING — plugin state under ~/.claude does NOT inherit the user-scope gate', () => {
// The regression this pins: dropping `plugin-managed` (or matching it after
// `user-scope`) makes every session write demand approval.
const r = classify(join(HOME, '.claude', 'config-audit', 'backups', 'b', 'files', 'CLAUDE.md'));
assert.equal(r.scopeClass, 'plugin-managed');
assert.equal(r.gate, 'silent');
});
test('another repo is disclosed, never blocked — campaign export is cross-repo by design', () => {
const r = classify('/Users/example/repos/other-plugin/docs/config-audit-plan-x.md');
assert.equal(r.scopeClass, 'cross-repo');
assert.equal(r.gate, 'disclose', 'blocking here would break a by-design feature (fasit §6 A1)');
assert.ok(r.disclosure.length > 0);
});
test('a path in no repo and no known root requires approval', () => {
const r = classify('/tmp/somewhere/else.md');
assert.equal(r.scopeClass, 'outside');
assert.equal(r.gate, 'require-ok');
});
// ---------------------------------------------------------------------------
// Robustness of the boundary itself
// ---------------------------------------------------------------------------
test('a sibling whose name merely PREFIXES the repo root is not in-repo', () => {
// `/Users/example/repos/my-plugin-2` starts with the repo root string; a
// `startsWith` implementation would call it in-repo and skip the gate.
const r = classify('/Users/example/repos/my-plugin-2/CLAUDE.md');
assert.notEqual(r.scopeClass, 'in-repo');
});
test('relative and unnormalised targets are resolved before classification', () => {
const viaDots = classify(join(REPO, 'docs', '..', 'CLAUDE.md'));
assert.equal(viaDots.scopeClass, 'in-repo');
const escaping = classify(join(REPO, '..', 'other-plugin', 'docs', 'x.md'));
assert.equal(escaping.scopeClass, 'cross-repo', 'a `..` that leaves the repo must be caught');
});
test('every classification result is internally consistent with SCOPE_CLASSES', () => {
const targets = [
join(HOME, '.claude', 'config-audit', 'x'),
join(HOME, '.config-audit', 'x'),
join(REPO, 'CLAUDE.md'),
join(HOME, '.claude', 'CLAUDE.md'),
'/Users/example/repos/other-plugin/docs/x.md',
'/tmp/x.md',
];
for (const t of targets) {
const r = classify(t);
const spec = SCOPE_CLASSES[r.scopeClass];
assert.ok(spec, `${r.scopeClass} must exist in SCOPE_CLASSES`);
assert.equal(r.gate, spec.gate, `gate for ${t} must come from the single source`);
assert.equal(r.disclosure, spec.disclosure, `disclosure for ${t} must come from the single source`);
}
});

View file

@ -60,8 +60,38 @@ const GUARDED = [
{ cli: 'token-hotspots-cli.mjs', argv: [], valueFlag: '--output-file' },
{ cli: 'whats-active.mjs', argv: [], valueFlag: '--output-file' },
{ cli: 'self-audit.mjs', argv: [], valueFlag: null }, // no value-taking flag
{ cli: 'write-scope-cli.mjs', argv: ['--target', 'x'], valueFlag: '--output-file' },
];
/**
* GUARDED was hand-maintained, and a hand-maintained list of what to sweep is a
* premise rather than a measurement the shape that made `KNOWN_OPEN` wrong
* about its own breadth (#57). Measured when this guard was added: the list was
* complete at 14 of 14, so nothing was hiding. It is the NEXT CLI that is at
* risk, so the coverage is derived from the directory instead of asserted about
* a literal.
*/
test('every argv-reading CLI in scanners/ appears in GUARDED', async () => {
const entries = await readdir(SCANNERS_DIR);
const clis = [];
for (const name of entries) {
if (!name.endsWith('.mjs')) continue;
const src = await readFile(resolve(SCANNERS_DIR, name), 'utf-8');
if (src.includes('process.argv')) clis.push(name);
}
const guarded = new Set(GUARDED.map((g) => g.cli));
const missing = clis.filter((c) => !guarded.has(c)).sort();
assert.deepEqual(
missing,
[],
'A CLI that reads process.argv but is absent from GUARDED is swept by nothing: it can\n' +
'accept an unknown flag, or write a file named after one, and both arms of this test\n' +
'would still be green. Add it to GUARDED rather than relaxing this check.',
);
});
function run(cli, argv, cwd = resolve(__dirname, '..', '..')) {
return new Promise((res) => {
const child = spawn(process.execPath, [resolve(SCANNERS_DIR, cli), ...argv], { cwd });