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
185 lines
7.9 KiB
JavaScript
185 lines
7.9 KiB
JavaScript
/**
|
|
* 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`);
|
|
}
|
|
});
|