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
114 lines
3.8 KiB
JavaScript
114 lines
3.8 KiB
JavaScript
#!/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;
|
|
}
|