/** * 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} */ 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, }, }; /** * Gate strengths, weakest first. Lives here rather than in a caller: a second * copy of this ordering would decide, independently, which gate a multi-target * write shows — the drift shape the class table itself exists to prevent. */ export const GATE_RANK = ['silent', 'disclose', 'require-ok']; /** * The strongest gate among already-classified targets. One `require-ok` target * in a set drives the whole surface: a run that would write machine-wide config * does not get to be quiet because most of its other targets are ordinary. * * @param {Array<{gate: string}>} targets * @returns {string} The strongest gate, or 'silent' when there are no targets. */ export 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; } /** * 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}`); }