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