voyage/tests/hooks/guards-review-regressions.test.mjs
Kjell Tore Guttormsen bef9c5b3ac
fix: the findings of the release review of 6.3.0
The review of v6.2.0..f669b87 (BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR), each
fixed behind a test that failed first, or, for the two missing tests, a test
shown to fail on a stub of the branch it covers.

- Review steps appended by /trekexecute Phase 7.2 are held to the step rules
  on any plan version: plan-validator --steps-from <N> (on a plan below 1.8 a
  template review step validated and was executed).
- The guard refuses where no one can be asked also when its rules will not
  load; the wave merge path names that refusal instead of MERGE_REFUSED.
  Tests for the classifier-error refusal and post-approval-log's own check.
- release-check: the version files may change only in their version after
  the review (JSON compared without it), README diff lines are read inside
  hunks only, an unreadable diff is exit 2, and npm test runs without
  NODE_TEST_CONTEXT.
- What counts as a step of only tests: a note in parentheses is not a path,
  and a data file under specs/ is not a test. End-to-end: rides on the last
  step that builds behaviour (validator hint, trekplan, template).
- A removed line is cited as <rev>:<path>:<line> (findings-schema hint,
  trekreview re-ask).
- skill-lint counts a trailing newline as no line; regression-check takes
  commandEnv from lib/util/command-env.mjs; CLAUDE.md names four mode-read
  exemptions.
- /security-review, older than v6.2.0: the criteria runner refuses git
  --output, which writes a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:12:12 +02:00

328 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';
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: { ...process.env, ...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: { ...process.env, 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);
});