Found by review after the SUB-WRITE commit, and both defects were in the template rather than the engine every prediction in the fasit was about. `--repo` is what a write target is classified AGAINST. The template passed the SCAN target, and under `--global` that target IS ~/.claude -- so ~/.claude/CLAUDE.md matched `in-repo` and the gate went `silent`. Measured against the real config: gate silent, scopeClass in-repo, 29 removals applied with no approval asked. That is the same silent downgrade #62 measured for a naive .git-upward walk, arriving through a different door, on the one target this chunk was sequenced behind M-BUG-41 to protect. Every other gated template already passed `--repo "$PWD"`; this one was the only outlier. The dry run also could not validate the machine-wide case -- the case that is mandatory in v1. The gate returned before any file was read, so a dry run there reported 29 scope-gate refusals and zero checked spans, and the first run able to find a stale approval would have been the one that writes. A gate guards a WRITE, and a dry run is not one: `requiresApproval` and the disclosures are still reported, so the operator is still asked. The new caller-arm guard was itself red against the corrected template, matching prose that merely NAMES the CLI. Narrowed to lines that invoke it. Guards seen red against the original defects: `--repo "<target-path>"` red, `--repo` omitted red, gate-blocks-dry-run red. Re-dogfooded as the template now calls it: require-ok / user-scope / 29 spans validated / 0 files written. Suite 1659 -> 1662/0. Frozen baselines untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017A6vrtPKsVuM4DJ27p7jzw
188 lines
7.8 KiB
JavaScript
188 lines
7.8 KiB
JavaScript
/**
|
|
* subtraction-write CLI — the exit-code contract and the payload the command
|
|
* template acts on (#63).
|
|
*
|
|
* The contract worth defending here is the one #62 settled: a gated write is a
|
|
* VERDICT, not a tool failure. Exit 3 means the CLI could not do its job; "this
|
|
* removal would touch your machine-wide config, approve it first" is an answer,
|
|
* and it has to arrive in the payload — a command that runs everything as
|
|
* `--output-file <path> 2>/dev/null` cannot act on anything that only reached
|
|
* stderr.
|
|
*/
|
|
|
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { join, resolve, dirname } from 'node:path';
|
|
import { mkdtemp, readFile, writeFile, rm, mkdir } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { spawn } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const CLI = resolve(__dirname, '..', '..', 'scanners', 'subtraction-write-cli.mjs');
|
|
|
|
const BLOCK_A = '- Always write tests before code, and never skip the failing step.';
|
|
const FIXTURE = ['# Project', '', '## Rules', '', BLOCK_A, '', '## End', ''].join('\n');
|
|
|
|
let dir;
|
|
let repo;
|
|
let file;
|
|
let approvedPath;
|
|
let outPath;
|
|
let env;
|
|
|
|
/** Run the CLI with a home and backup root that are never the operator's. */
|
|
function run(argv) {
|
|
return new Promise((res) => {
|
|
const child = spawn(process.execPath, [CLI, ...argv], { cwd: repo, env });
|
|
let stderr = '';
|
|
let stdout = '';
|
|
child.stderr.on('data', (d) => { stderr += d; });
|
|
child.stdout.on('data', (d) => { stdout += d; });
|
|
child.on('close', (code) => res({ code, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), 'config-audit-subwrite-cli-'));
|
|
repo = join(dir, 'repo');
|
|
await mkdir(join(repo, '.git'), { recursive: true });
|
|
file = join(repo, 'CLAUDE.md');
|
|
await writeFile(file, FIXTURE, 'utf-8');
|
|
|
|
approvedPath = join(dir, 'approved.json');
|
|
outPath = join(dir, 'result.json');
|
|
env = {
|
|
...process.env,
|
|
HOME: join(dir, 'home'),
|
|
CONFIG_AUDIT_BACKUP_ROOT: join(dir, 'backups'),
|
|
};
|
|
await mkdir(join(dir, 'home', '.claude'), { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function writeApproval(removals) {
|
|
await writeFile(approvedPath, JSON.stringify({ sessionId: 'test', removals }), 'utf-8');
|
|
}
|
|
|
|
describe('subtraction-write-cli', () => {
|
|
it('applies an approved removal and reports it in the payload', async () => {
|
|
await writeApproval([{ file, line: 5, endLine: 5, text: BLOCK_A }]);
|
|
const { code } = await run(['--approved', approvedPath, '--repo', repo, '--output-file', outPath]);
|
|
|
|
assert.equal(code, 0);
|
|
const payload = JSON.parse(await readFile(outPath, 'utf-8'));
|
|
assert.equal(payload.counts.applied, 1);
|
|
assert.equal(payload.counts.filesWritten, 1);
|
|
assert.ok(payload.backupId, 'a verified backup must precede the write');
|
|
assert.equal(payload.applied[0].text, BLOCK_A, 'the receipt carries what left the file');
|
|
assert.ok(!(await readFile(file, 'utf-8')).includes(BLOCK_A));
|
|
});
|
|
|
|
it('gates a machine-wide target with exit 0 and a disclosure, writing nothing', async () => {
|
|
const userFile = join(dir, 'home', '.claude', 'CLAUDE.md');
|
|
await writeFile(userFile, FIXTURE, 'utf-8');
|
|
await writeApproval([{ file: userFile, line: 5, endLine: 5, text: BLOCK_A }]);
|
|
|
|
const { code } = await run(['--approved', approvedPath, '--repo', repo, '--output-file', outPath]);
|
|
|
|
assert.equal(code, 0, 'a gated write is a verdict about a write, never exit 3 (#62)');
|
|
const payload = JSON.parse(await readFile(outPath, 'utf-8'));
|
|
assert.equal(payload.gate, 'require-ok');
|
|
assert.equal(payload.requiresApproval, true);
|
|
assert.ok(
|
|
payload.disclosures.some((d) => /machine-wide/i.test(d)),
|
|
'the payload must carry WHY, not just that it refused',
|
|
);
|
|
assert.equal(payload.counts.applied, 0);
|
|
assert.equal(payload.counts.filesWritten, 0);
|
|
assert.equal(await readFile(userFile, 'utf-8'), FIXTURE);
|
|
});
|
|
|
|
it('dry-runs a machine-wide target: spans validated, gate still reported, nothing written', async () => {
|
|
// The template's Step 7c promise ("the dry run proves the spans before
|
|
// anything is written") has to hold on the machine-wide target too — that
|
|
// is the mandatory v1 case. A gate that also blocked the dry run made 7c
|
|
// decorative exactly where it mattered.
|
|
const userFile = join(dir, 'home', '.claude', 'CLAUDE.md');
|
|
await writeFile(userFile, FIXTURE, 'utf-8');
|
|
await writeApproval([{ file: userFile, line: 5, endLine: 5, text: BLOCK_A }]);
|
|
|
|
const { code } = await run([
|
|
'--approved', approvedPath, '--repo', repo, '--dry-run', '--output-file', outPath,
|
|
]);
|
|
|
|
assert.equal(code, 0);
|
|
const payload = JSON.parse(await readFile(outPath, 'utf-8'));
|
|
assert.equal(payload.requiresApproval, true, 'the operator must still be asked');
|
|
assert.ok(payload.disclosures.length >= 1);
|
|
assert.equal(payload.counts.applied, 1, 'the span was actually checked, not refused unseen');
|
|
assert.equal(payload.counts.filesWritten, 0);
|
|
assert.equal(await readFile(userFile, 'utf-8'), FIXTURE);
|
|
});
|
|
|
|
it('proceeds on that same target with --approve-scope', async () => {
|
|
const userFile = join(dir, 'home', '.claude', 'CLAUDE.md');
|
|
await writeFile(userFile, FIXTURE, 'utf-8');
|
|
await writeApproval([{ file: userFile, line: 5, endLine: 5, text: BLOCK_A }]);
|
|
|
|
const { code } = await run([
|
|
'--approved', approvedPath, '--repo', repo, '--approve-scope', '--output-file', outPath,
|
|
]);
|
|
|
|
assert.equal(code, 0);
|
|
const payload = JSON.parse(await readFile(outPath, 'utf-8'));
|
|
assert.equal(payload.counts.applied, 1);
|
|
assert.ok(!(await readFile(userFile, 'utf-8')).includes(BLOCK_A));
|
|
});
|
|
|
|
it('--dry-run reports the removal and leaves the file alone', async () => {
|
|
await writeApproval([{ file, line: 5, endLine: 5, text: BLOCK_A }]);
|
|
const { code } = await run([
|
|
'--approved', approvedPath, '--repo', repo, '--dry-run', '--output-file', outPath,
|
|
]);
|
|
|
|
assert.equal(code, 0);
|
|
const payload = JSON.parse(await readFile(outPath, 'utf-8'));
|
|
assert.equal(payload.meta.dryRun, true);
|
|
assert.equal(payload.counts.applied, 1);
|
|
assert.equal(payload.counts.filesWritten, 0);
|
|
assert.equal(payload.backupId, null);
|
|
assert.equal(await readFile(file, 'utf-8'), FIXTURE);
|
|
});
|
|
|
|
it('a stale approval is refused in the payload, not as a tool error', async () => {
|
|
await writeApproval([{ file, line: 5, endLine: 5, text: '- A block that is not there.' }]);
|
|
const { code } = await run(['--approved', approvedPath, '--repo', repo, '--output-file', outPath]);
|
|
|
|
assert.equal(code, 0);
|
|
const payload = JSON.parse(await readFile(outPath, 'utf-8'));
|
|
assert.equal(payload.counts.applied, 0);
|
|
assert.equal(payload.refused[0].reason, 'block-mismatch');
|
|
assert.equal(await readFile(file, 'utf-8'), FIXTURE);
|
|
});
|
|
|
|
it('exits 3 without --approved', async () => {
|
|
const { code, stderr } = await run(['--repo', repo]);
|
|
assert.equal(code, 3);
|
|
assert.match(stderr, /--approved/);
|
|
});
|
|
|
|
it('exits 3 on an approval file with no removals — an empty set is not "all done"', async () => {
|
|
await writeFile(approvedPath, JSON.stringify({ removals: [] }), 'utf-8');
|
|
const { code, stderr } = await run(['--approved', approvedPath, '--repo', repo]);
|
|
assert.equal(code, 3);
|
|
assert.match(stderr, /removals/);
|
|
});
|
|
|
|
it('exits 3 on a removal missing its text — an unverifiable approval is not a licence to delete', async () => {
|
|
await writeApproval([{ file, line: 5, endLine: 5 }]);
|
|
const { code, stderr } = await run(['--approved', approvedPath, '--repo', repo]);
|
|
assert.equal(code, 3);
|
|
assert.match(stderr, /text/);
|
|
assert.equal(await readFile(file, 'utf-8'), FIXTURE);
|
|
});
|
|
});
|