/** * 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`); } });