/simplify over a97efb6..HEAD (the plan_version 1.9 / trial-4 delivery). - coordinator-contract: engine names come from engine-findings ENGINES; one markedMajorCodeReview predicate under both lowering checks; isEngineFinding everywhere; the unreachable branch (c) of illegalBriefLowering folded into (e). - armB workflow: RULE_KEYS = Object.keys(RULE_TIERS); the engine map is a literal pinned equal to ENGINES by the workflow test. - plan-schema: usesFilesGrammar() is the one 1.9 check (validator, sweep, testFirstFiles, stepTestsOnlyFromFields); opensEntry built on opensChunk. - plan-validator: one issue builder for the Files and Test first grammar. - plan-scope-sweep: the 1.9 and the 6.2.0 reader are one if/else. - artifact-commit: TRAILER_RE/trailerProblem no longer exported (no importer); the order comment says the CLI finds the repo root first. - tests: the lock drivers in session-id-isolation (d) run side by side per mode (~100 s -> ~30 s); a shared artifact-repo helper; severityRank and extractFindingsBlock reused; childEnv(env) without a spread; one copy of the PLAN_VERSION_CURRENT check. Plan readers: validator errors/warnings, plannedPaths, fieldPaths, testFirstFiles and stepTestsOnlyFromFields compared byte for byte against HEAD over 510 plans / 1692 steps plus 210 synthetic 1.7-1.9 cases: same. Suite 2384 (2382/0/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
329 lines
17 KiB
JavaScript
329 lines
17 KiB
JavaScript
// tests/hooks/guards-review-regressions.test.mjs
|
|
// Regressions from the independent review of the wave-1 guards (06.10). Each
|
|
// test reproduces one finding the first test set did not cover; all failed
|
|
// before the fix.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { runHook, runHookWithEnv } from '../helpers/hook-helper.mjs';
|
|
import { ROOT, makeRepo, gitIn, fakeTool, tmp, pluginCopy, readStubLog, parseJsonOut } from './guard-helpers.mjs';
|
|
import { childEnv } from '../helpers/child-env.mjs';
|
|
|
|
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
|
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
|
|
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
|
|
const LOCK = join(ROOT, 'lib', 'util', 'test-lock.mjs');
|
|
const STEP = join(ROOT, 'lib', 'verification', 'step-check.mjs');
|
|
// Approval records share one temp dir per user, keyed by session_id +
|
|
// tool_use_id: fixed ids let two suites running at once take each other's.
|
|
const RUN = `${process.pid}-${Date.now()}`;
|
|
const { classifyGitApproval } = await import(join(ROOT, 'hooks', 'lib', 'git-approval.mjs'));
|
|
|
|
const FEAT = makeRepo('main');
|
|
gitIn(FEAT, 'checkout', '-q', '-b', 'feat');
|
|
const MAIN = makeRepo('main');
|
|
|
|
async function decision(command, cwd = FEAT) {
|
|
const r = await runHook(PRE_BASH, { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } });
|
|
let out = null;
|
|
try { out = parseJsonOut(r.stdout); } catch { out = null; }
|
|
return out?.hookSpecificOutput?.permissionDecision ?? (r.code === 2 ? 'block' : null);
|
|
}
|
|
|
|
// 1. Quotes that are not quotes
|
|
for (const cmd of [
|
|
"# push it, it's ready\ngit push origin main",
|
|
"echo don\\'t; git push origin main",
|
|
"echo 'unterminated; git push origin main",
|
|
]) {
|
|
test(`review 1: an apostrophe that is not a closed quote does not hide a push: ${JSON.stringify(cmd)}`, async () => {
|
|
assert.equal(await decision(cmd), 'ask');
|
|
});
|
|
}
|
|
|
|
// 2. Quoted program, subcommand or ref
|
|
for (const cmd of ['git "push" origin feat', '"git" push', "'git' push", "git 'push' origin feat"]) {
|
|
test(`review 2: a quoted word is still the word: ${cmd}`, async () => {
|
|
assert.equal(await decision(cmd), 'ask');
|
|
});
|
|
}
|
|
test('review 2: a quoted ref keeps its kind (push to "main" is a merge approval)', () => {
|
|
assert.deepEqual(classifyGitApproval('git push origin "main"', { branch: 'feat' }).map((m) => m.kind), ['merge']);
|
|
});
|
|
test('review 2: quoted data with spaces stays data', async () => {
|
|
assert.equal(await decision('echo "git push origin main"'), null);
|
|
assert.equal(await decision('git commit -m "wip"'), null);
|
|
});
|
|
|
|
// 3. Wrappers
|
|
for (const cmd of ['timeout 60 git push', 'nice git push', 'nice -n 5 git push', 'env -i git push', 'env -u HOME git push',
|
|
'sudo -u bob git push', 'command -p git push', 'time -p git push', 'eval git push', 'xargs -n 1 git push']) {
|
|
test(`review 3: wrapper seen through: ${cmd}`, async () => {
|
|
assert.equal(await decision(cmd), 'ask');
|
|
});
|
|
}
|
|
|
|
// 8. One branch model for the whole command
|
|
test('review 8: switching to main then pushing is a push to main', () => {
|
|
assert.deepEqual(classifyGitApproval('git checkout main && git push', { branch: 'feat' }).map((m) => m.kind), ['merge']);
|
|
});
|
|
test('review 8: switching away from main then merging is not a merge into main', () => {
|
|
assert.deepEqual(classifyGitApproval('git checkout feat && git merge x', { branch: 'main' }), []);
|
|
});
|
|
test('review 8: git -C <dir> reads that repo\'s branch', () => {
|
|
assert.deepEqual(classifyGitApproval(`git -C ${MAIN} merge x`, { cwd: FEAT }).map((m) => m.kind), ['merge']);
|
|
});
|
|
test('review 8: cd <dir> && ... reads that repo\'s branch', () => {
|
|
assert.deepEqual(classifyGitApproval(`cd ${MAIN} && git merge x`, { cwd: FEAT }).map((m) => m.kind), ['merge']);
|
|
});
|
|
|
|
test('review 8: the log records what the person was asked, not a re-reading after the command', async () => {
|
|
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
|
const repo = makeRepo('main');
|
|
gitIn(repo, 'checkout', '-q', '-b', 'feat');
|
|
const cmd = 'git checkout main && git merge feat';
|
|
const base = { session_id: `s-8-${RUN}`, tool_use_id: 'toolu_8', cwd: repo, tool_name: 'Bash', tool_input: { command: cmd } };
|
|
const pre = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
|
|
assert.match(pre.stdout, /merge into main/);
|
|
gitIn(repo, 'checkout', '-q', 'main'); // the command ran
|
|
const log = join(tmp(), 'stub.jsonl');
|
|
await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), { ...base, hook_event_name: 'PostToolUse' }, { STUB_LOG: log });
|
|
const calls = readStubLog(log);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].argv[calls[0].argv.indexOf('--kind') + 1], 'merge');
|
|
});
|
|
|
|
test('review 8: a command nobody was asked about is not logged, even if it reads like one afterwards', async () => {
|
|
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
|
const repo = makeRepo('main');
|
|
const log = join(tmp(), 'stub.jsonl');
|
|
await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), {
|
|
hook_event_name: 'PostToolUse', session_id: `s-9-${RUN}`, tool_use_id: 'toolu_never_asked', cwd: repo,
|
|
tool_name: 'Bash', tool_input: { command: 'git push origin main' },
|
|
}, { STUB_LOG: log });
|
|
assert.equal(readStubLog(log).length, 0);
|
|
});
|
|
|
|
// Operator decision 2026-10-09: where no person can be asked, the guard refuses
|
|
// a merge into main or a push (exit 2) instead of asking, so the call never runs
|
|
// and nothing reaches the approval log. post-approval-log's own check of the
|
|
// recorded mode stays as a second line (its own test below).
|
|
const NO_PROMPT_MODE = 'bypassPermissions';
|
|
test('uncertain (no-prompt mode): the push to main is refused, and no approval is logged', async () => {
|
|
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
|
const repo = makeRepo('main');
|
|
const base = { session_id: `s-b-${RUN}`, tool_use_id: 'toolu_b', cwd: repo, tool_name: 'Bash', tool_input: { command: 'git push origin main' }, permission_mode: NO_PROMPT_MODE };
|
|
const pre = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
|
|
assert.equal(pre.code, 2, `refused, not asked: ${JSON.stringify(pre)}`);
|
|
const log = join(tmp(), 'stub.jsonl');
|
|
await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), { ...base, hook_event_name: 'PostToolUse' }, { STUB_LOG: log });
|
|
assert.equal(readStubLog(log).length, 0);
|
|
});
|
|
|
|
// The second line on its own (release review of 6.3.0, 2026-10-10): a record
|
|
// that says no person was asked is never logged, whoever wrote it.
|
|
test('uncertain (no-prompt mode): a recorded question no one saw is not logged', async () => {
|
|
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
|
const repo = makeRepo('main');
|
|
const { writeApprovalRecord } = await import(join(plugin, 'hooks', 'lib', 'approval-record.mjs'));
|
|
const ids = { session_id: `s-r-${RUN}`, tool_use_id: 'toolu_r' };
|
|
assert.ok(writeApprovalRecord(ids.session_id, ids.tool_use_id, {
|
|
command: 'git push origin main', permission_mode: NO_PROMPT_MODE,
|
|
matches: [{ kind: 'merge', name: 'push to main', subject: repo }], asked_at: new Date().toISOString(),
|
|
}), 'the record is written');
|
|
const log = join(tmp(), 'stub.jsonl');
|
|
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), {
|
|
...ids, hook_event_name: 'PostToolUse', cwd: repo, tool_name: 'Bash', tool_input: { command: 'git push origin main' },
|
|
}, { STUB_LOG: log });
|
|
assert.match(parseJsonOut(r.stdout).hookSpecificOutput.additionalContext, /not logged/i);
|
|
assert.equal(readStubLog(log).length, 0);
|
|
});
|
|
|
|
// 9. Classification
|
|
const kinds = (cmd, branch = 'feat', cwd) => classifyGitApproval(cmd, { branch, cwd }).map((m) => m.kind);
|
|
for (const cmd of ['git tag -v v1', 'git tag --verify v1', 'git push -n', 'git push --dry-run origin main']) {
|
|
test(`review 9: not asked: ${cmd}`, () => assert.deepEqual(kinds(cmd), []));
|
|
}
|
|
for (const cmd of ['npm --tag beta publish', 'pnpm -r publish', 'yarn npm publish', 'npx npm publish', 'gh release create v1']) {
|
|
test(`review 9: release: ${cmd}`, () => assert.deepEqual(kinds(cmd), ['release']));
|
|
}
|
|
test('review 9: pushing an existing tag by name is a release', () => {
|
|
const repo = makeRepo('main');
|
|
gitIn(repo, 'tag', 'v1.0');
|
|
assert.deepEqual(kinds('git push origin v1.0', 'main', repo), ['release']);
|
|
});
|
|
for (const cmd of ['git checkout HEAD file.txt', 'git switch -f main', 'git switch --discard-changes main', 'git branch -M main']) {
|
|
test(`review 9: destructive: ${cmd}`, () => assert.deepEqual(kinds(cmd, 'feat'), [null]));
|
|
}
|
|
test('review 9: deleting main on the remote is destructive, not a merge approval', () => {
|
|
assert.deepEqual(kinds('git push origin :main'), [null]);
|
|
assert.deepEqual(kinds('git push origin --delete main'), [null]);
|
|
});
|
|
|
|
// 5 + 6 + 7. The write guard
|
|
test('review 5: a lock module that cannot load blocks the write (fail closed), the path rules still run', async () => {
|
|
const plugin = pluginCopy({ 'lib/util/test-lock.mjs': 'missing' });
|
|
const hook = join(plugin, 'hooks', 'scripts', 'pre-write-executor.mjs');
|
|
const r = await runHook(hook, { tool_name: 'Edit', tool_input: { file_path: join(makeRepo('main'), 'a.js') } });
|
|
assert.equal(r.code, 2);
|
|
assert.match(r.stderr, /could not load/i);
|
|
});
|
|
test('review 5: a guard module that cannot load makes the Bash guard ask, never pass silently', async () => {
|
|
const plugin = pluginCopy({ 'hooks/lib/git-approval.mjs': 'missing' });
|
|
const r = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { tool_name: 'Bash', tool_input: { command: 'ls' } });
|
|
assert.equal(parseJsonOut(r.stdout)?.hookSpecificOutput?.permissionDecision, 'ask');
|
|
});
|
|
|
|
function lockIn(repo, rel, env = {}) {
|
|
const r = spawnSync(process.execPath, [LOCK, 'lock', '--files', rel, '--step', '1'], { cwd: repo, encoding: 'utf8', env: childEnv(env) });
|
|
assert.equal(r.status, 0, r.stderr);
|
|
}
|
|
|
|
test('review 6: another letter case does not get past the lock (case-insensitive file systems)', async () => {
|
|
const repo = makeRepo('main');
|
|
mkdirSync(join(repo, 'tests'));
|
|
writeFileSync(join(repo, 'tests', 'a.test.mjs'), 'x');
|
|
lockIn(repo, 'tests/a.test.mjs');
|
|
const upper = join(repo, 'Tests', 'a.test.mjs');
|
|
let caseInsensitive = false;
|
|
try { realpathSync(upper); caseInsensitive = true; } catch { caseInsensitive = false; }
|
|
const r = await runHook(PRE_WRITE, { tool_name: 'Edit', tool_input: { file_path: upper } });
|
|
if (caseInsensitive) assert.equal(r.code, 2, 'same file, other case');
|
|
else assert.equal(r.code, 0, 'a different file on a case-sensitive file system');
|
|
});
|
|
|
|
test('review 7: the lock file itself cannot be written with the file tools (repo and worktree git dirs)', async () => {
|
|
for (const p of ['/r/.git/voyage/test-lock.json', '/r/.git/worktrees/wt/voyage/test-lock.json', '/r/.git/voyage/post-edit.jsonl']) {
|
|
const r = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: { file_path: p, content: '{}' } });
|
|
assert.equal(r.code, 2, p);
|
|
}
|
|
});
|
|
|
|
// 10. The stop guard finds the session's lock wherever the session now stands
|
|
const SESS_MOVED = `sess-moved-${process.pid}-${Date.now()}`;
|
|
test('review 10: the stop guard finds the lock taken by this session from another directory', async () => {
|
|
const repo = makeRepo('main');
|
|
writeFileSync(join(repo, 'a.test.js'), 'x');
|
|
lockIn(repo, 'a.test.js', { CLAUDE_CODE_SESSION_ID: SESS_MOVED });
|
|
const r = await runHook(STOP, { hook_event_name: 'Stop', session_id: SESS_MOVED, cwd: tmp(), stop_hook_active: false });
|
|
assert.equal(parseJsonOut(r.stdout)?.decision, 'block');
|
|
spawnSync(process.execPath, [LOCK, 'unlock'], { cwd: repo });
|
|
const after = await runHook(STOP, { hook_event_name: 'Stop', session_id: SESS_MOVED, cwd: tmp(), stop_hook_active: false });
|
|
assert.equal(parseJsonOut(after.stdout), null, 'unlock clears it');
|
|
});
|
|
|
|
// 4, 11, 12. The step check
|
|
function runStep(repo) {
|
|
const r = spawnSync(process.execPath, [STEP, '--cwd', repo], { encoding: 'utf8', env: childEnv({ PATH: '/usr/bin:/bin' }) });
|
|
return { code: r.status, json: JSON.parse(r.stdout) };
|
|
}
|
|
function jsRepo() {
|
|
const repo = makeRepo('main');
|
|
writeFileSync(join(repo, '.prettierrc'), '{}');
|
|
writeFileSync(join(repo, '.gitignore'), 'node_modules/\n');
|
|
gitIn(repo, 'add', '.prettierrc', '.gitignore');
|
|
gitIn(repo, 'commit', '-q', '-m', 'tools');
|
|
fakeTool(join(repo, 'node_modules', '.bin'), 'prettier');
|
|
return repo;
|
|
}
|
|
test('review 4: a configured tool that crashed is not green', () => {
|
|
const repo = jsRepo();
|
|
writeFileSync(join(repo, 'eslint.config.js'), 'export default []');
|
|
// eslint is configured and installed, but crashes (exit 2 = config error) on every call
|
|
writeFileSync(join(repo, 'node_modules', '.bin', 'eslint'), '#!/bin/sh\nexit 2\n', { mode: 0o755 });
|
|
writeFileSync(join(repo, 'a.js'), 'ok'); // prettier: clean
|
|
const r = runStep(repo);
|
|
assert.equal(r.code, 2, JSON.stringify(r.json));
|
|
assert.equal(r.json.status, 'not_measured');
|
|
});
|
|
test('review 4: changed files no tool covers are listed, never silently dropped', () => {
|
|
const repo = jsRepo();
|
|
writeFileSync(join(repo, 'a.js'), 'ok');
|
|
writeFileSync(join(repo, 'b.py'), 'x = 1');
|
|
const r = runStep(repo);
|
|
assert.deepEqual(r.json.not_covered, ['b.py']);
|
|
});
|
|
test('review 11: a changed file whose name starts with "-" reaches the tool as a file, not a flag', () => {
|
|
const repo = jsRepo();
|
|
writeFileSync(join(repo, '-x.js'), 'BAD');
|
|
const r = runStep(repo);
|
|
assert.equal(r.code, 1, JSON.stringify(r.json));
|
|
});
|
|
test('review 12: a changed file with a non-ASCII name is checked', () => {
|
|
const repo = jsRepo();
|
|
writeFileSync(join(repo, 'blåbær.js'), 'BAD');
|
|
const r = runStep(repo);
|
|
assert.equal(r.code, 1, JSON.stringify(r.json));
|
|
});
|
|
|
|
test('uncertain (relative plan path): the plan gate resolves planFilePath against the session cwd', async () => {
|
|
const plugin = pluginCopy({ 'lib/validators/plan-gate.mjs': 'stub' });
|
|
const dir = tmp();
|
|
writeFileSync(join(dir, 'plan.md'), '---\nplan_version: 1.7\n---\n');
|
|
const log = join(tmp(), 'stub.jsonl');
|
|
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'pre-plan-gate.mjs'), {
|
|
hook_event_name: 'PreToolUse', tool_name: 'ExitPlanMode', cwd: dir,
|
|
tool_input: { plan: '---\nplan_version: 1.7\n---\n', planFilePath: 'plan.md' },
|
|
}, { STUB_LOG: log, STUB_EXIT: '1' });
|
|
assert.equal(r.code, 2, r.stdout);
|
|
assert.equal(readStubLog(log)[0].argv[0], join(dir, 'plan.md'));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Second review round (06.10): what the first fixes left open or introduced.
|
|
// ---------------------------------------------------------------------------
|
|
for (const cmd of [
|
|
'git commit -m "fix \\"x\\" bug" && git push origin main && echo "ok"',
|
|
'echo "a\\"b" ; git push origin main ; echo "c"',
|
|
'if git push origin main; then echo ok; fi',
|
|
'while git push origin main; do sleep 1; done',
|
|
"eval 'git push origin main'",
|
|
'echo a\\ #b; git push origin main',
|
|
]) {
|
|
test(`review 2nd round: still asks: ${JSON.stringify(cmd)}`, async () => {
|
|
assert.equal(await decision(cmd), 'ask');
|
|
});
|
|
}
|
|
|
|
test('review 2nd round (7): the .git/voyage block ignores letter case', async () => {
|
|
for (const p of ['/r/.GIT/voyage/test-lock.json', '/r/.git/VOYAGE/test-lock.json', '/r/.Git/hooks/pre-commit']) {
|
|
const r = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: { file_path: p, content: '{}' } });
|
|
assert.equal(r.code, 2, p);
|
|
}
|
|
});
|
|
|
|
test('review 2nd round (N1): redirections are not file arguments', () => {
|
|
assert.deepEqual(kinds('git checkout main 2>/dev/null && git merge x', 'feat'), ['merge']);
|
|
assert.deepEqual(kinds('git checkout feat2 2>&1', 'feat'), []);
|
|
assert.deepEqual(kinds('git push origin 2>&1', 'main'), ['merge']);
|
|
assert.deepEqual(kinds('git checkout main > /dev/null && git merge x', 'feat'), ['merge']);
|
|
});
|
|
|
|
for (const cmd of ['npm publish --dry-run', 'cargo publish --dry-run', 'npm view x publish', 'yarn info publish']) {
|
|
test(`review 2nd round (N2): not a release: ${cmd}`, () => assert.deepEqual(kinds(cmd), []));
|
|
}
|
|
|
|
test('review 2nd round (N5): a detached switch leaves no branch to merge into', () => {
|
|
assert.deepEqual(kinds('git switch --detach main && git merge x', 'feat'), []);
|
|
assert.deepEqual(kinds('git checkout --detach main && git merge x', 'feat'), []);
|
|
});
|
|
|
|
test('review 2nd round: a push option value is not the remote', () => {
|
|
assert.deepEqual(kinds('git push -o ci.skip origin main', 'feat'), ['merge']);
|
|
});
|
|
|
|
test('review 2nd round (N4): when the record cannot be written, the prompt says the approval will not be logged', async () => {
|
|
const plugin = pluginCopy({});
|
|
// TMPDIR points at a file, so the record directory cannot be created.
|
|
const fileAsDir = join(tmp(), 'not-a-dir');
|
|
writeFileSync(fileAsDir, 'x');
|
|
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), {
|
|
hook_event_name: 'PreToolUse', session_id: `s-n4-${RUN}`, tool_use_id: 'toolu_n4', cwd: makeRepo('main'),
|
|
tool_name: 'Bash', tool_input: { command: 'git push origin main' },
|
|
}, { TMPDIR: fileAsDir });
|
|
const reason = parseJsonOut(r.stdout).hookSpecificOutput.permissionDecisionReason;
|
|
assert.match(reason, /will not be logged/i);
|
|
});
|