Wave-1 order 4 (P7, P12, the P9 test lock, the P3 plan-approval guard). All command hooks; none runs a model. - P12: pre-bash-executor answers permissionDecision "ask" before merge into main, push, tag/publish and destructive git (holds in auto mode). post-approval-log writes exactly what the person was asked to the K2 approval log: merge/push to main as `merge`, tag/publish as `release`, with a one-hour expiry; nothing when no person was asked. - P3: pre-plan-gate runs the K3 plan gate on a Voyage plan leaving plan mode and sends a refused plan back; "not measured" asks, never passes. - P9: lib/util/test-lock.mjs (K8). pre-write-executor refuses edits to locked test files; stop-test-lock refuses "done" for the locking session (stop_hook_active guarded). - P7: post-edit-check runs the target repo's own formatter/linter per edit, only during a Voyage plan run or after opt-in (.claude/voyage-guards.json); lib/verification/step-check.mjs (K9) fails the step on findings; no tool reads "not measured", never green. - STORM cap matcher also names `Agent` (the tool's name since 2.1.63). - Mod precondition stated in README, SECURITY.md and every PreToolUse stop guard: Voyage's guards are deterministic only when no installed mod handles `tool.check`. K2, K3 and K5 are stubbed in tests until orders 2 and 3 are merged. Two independent review rounds; every finding has a regression test. Suite 1600 (1598 pass / 0 fail / 2 skipped). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
6 KiB
JavaScript
131 lines
6 KiB
JavaScript
// tests/hooks/test-lock-guard.test.mjs
|
|
// P9 (3) — the lock on the tests. Two guards read the K8 lock:
|
|
// - the write guard (pre-write-executor.mjs, PreToolUse Write|Edit|
|
|
// NotebookEdit) refuses a change to a locked test file;
|
|
// - the stop guard (stop-test-lock.mjs, Stop) refuses "done" while the step
|
|
// that took the lock in THIS session still holds it, with the
|
|
// stop_hook_active guard (code.claude.com/docs/en/hooks § Stop input).
|
|
// Pattern: Claude Code's own hooks (hooks-guide «Block edits to protected
|
|
// files»; hooks reference § TaskCompleted/Stop decision control).
|
|
|
|
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 } from '../helpers/hook-helper.mjs';
|
|
import { ROOT, scriptsFor, makeRepo, parseJsonOut } from './guard-helpers.mjs';
|
|
|
|
const CLI = join(ROOT, 'lib', 'util', 'test-lock.mjs');
|
|
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
|
|
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
|
|
|
|
const RUN = `${process.pid}-${Date.now()}`;
|
|
const SESS_A = `sess-A-${RUN}`;
|
|
const SESS_B = `sess-B-${RUN}`;
|
|
|
|
function lockedRepo({ session = SESS_A, step = 2 } = {}) {
|
|
const repo = makeRepo('main');
|
|
mkdirSync(join(repo, 'tests'), { recursive: true });
|
|
writeFileSync(join(repo, 'tests', 'locked.test.js'), 'x\n');
|
|
writeFileSync(join(repo, 'tests', 'free.test.js'), 'y\n');
|
|
writeFileSync(join(repo, 'nb.ipynb'), '{}\n');
|
|
const r = spawnSync(process.execPath, [CLI, 'lock', '--files', 'tests/locked.test.js,nb.ipynb', '--step', String(step)], {
|
|
cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: session },
|
|
});
|
|
assert.equal(r.status, 0, `lock failed: ${r.stderr}`);
|
|
return repo;
|
|
}
|
|
|
|
const edit = (file_path) => ({ hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path, old_string: 'x', new_string: 'z' } });
|
|
|
|
test('P9 lock must stop: Edit on a locked test file -> exit 2, names the step', async () => {
|
|
const repo = lockedRepo();
|
|
const r = await runHook(PRE_WRITE, edit(join(repo, 'tests', 'locked.test.js')));
|
|
assert.equal(r.code, 2, r.stderr);
|
|
assert.match(r.stderr, /locked/i);
|
|
assert.match(r.stderr, /step 2/);
|
|
assert.match(r.stderr, /operator/, 'the way forward: a wrong test goes to the operator');
|
|
});
|
|
|
|
test('P9 lock must stop: the same file reached through a symlinked path (/tmp vs /private/tmp)', async () => {
|
|
const repo = lockedRepo();
|
|
const real = realpathSync(repo);
|
|
const alias = real.startsWith('/private/') ? real.slice('/private'.length) : real;
|
|
const r = await runHook(PRE_WRITE, edit(join(alias, 'tests', 'locked.test.js')));
|
|
assert.equal(r.code, 2);
|
|
});
|
|
|
|
test('P9 lock must stop: Write and NotebookEdit too', async () => {
|
|
const repo = lockedRepo();
|
|
const w = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: { file_path: join(repo, 'tests', 'locked.test.js'), content: '' } });
|
|
assert.equal(w.code, 2);
|
|
const n = await runHook(PRE_WRITE, { tool_name: 'NotebookEdit', tool_input: { notebook_path: join(repo, 'nb.ipynb'), new_source: '' } });
|
|
assert.equal(n.code, 2);
|
|
});
|
|
|
|
test('P9 lock must pass: Edit on an unlocked file in the same repo', async () => {
|
|
const repo = lockedRepo();
|
|
const r = await runHook(PRE_WRITE, edit(join(repo, 'tests', 'free.test.js')));
|
|
assert.equal(r.code, 0, r.stderr);
|
|
});
|
|
|
|
test('P9 lock must pass: a repo with no lock at all', async () => {
|
|
const repo = makeRepo('main');
|
|
const r = await runHook(PRE_WRITE, edit(join(repo, 'README.md')));
|
|
assert.equal(r.code, 0, r.stderr);
|
|
});
|
|
|
|
test('P9 lock must pass: after unlock', async () => {
|
|
const repo = lockedRepo();
|
|
spawnSync(process.execPath, [CLI, 'unlock'], { cwd: repo });
|
|
const r = await runHook(PRE_WRITE, edit(join(repo, 'tests', 'locked.test.js')));
|
|
assert.equal(r.code, 0, r.stderr);
|
|
});
|
|
|
|
test('P9 lock fails closed: an unreadable lock blocks writes in that repo', async () => {
|
|
const repo = lockedRepo();
|
|
const st = JSON.parse(spawnSync(process.execPath, [CLI, 'status'], { cwd: repo, encoding: 'utf8' }).stdout);
|
|
writeFileSync(st.lock_path, '{not json');
|
|
const r = await runHook(PRE_WRITE, edit(join(repo, 'README.md')));
|
|
assert.equal(r.code, 2);
|
|
assert.ok(r.stderr.includes(st.lock_path), 'the message names the lock file to inspect');
|
|
});
|
|
|
|
const stop = (repo, extra = {}) => ({ hook_event_name: 'Stop', session_id: SESS_A, cwd: repo, stop_hook_active: false, ...extra });
|
|
|
|
test('P9 stop must stop: "done" refused while this session\'s step holds the lock', async () => {
|
|
const repo = lockedRepo();
|
|
const r = await runHook(STOP, stop(repo));
|
|
assert.equal(r.code, 0);
|
|
const out = parseJsonOut(r.stdout);
|
|
assert.equal(out?.decision, 'block', r.stdout);
|
|
assert.match(out.reason, /step 2/);
|
|
});
|
|
|
|
test('P9 stop must pass: stop_hook_active is true (no endless loop)', async () => {
|
|
const repo = lockedRepo();
|
|
const r = await runHook(STOP, stop(repo, { stop_hook_active: true }));
|
|
assert.equal(r.code, 0);
|
|
assert.equal(parseJsonOut(r.stdout), null);
|
|
});
|
|
|
|
test('P9 stop must pass: the lock belongs to another session', async () => {
|
|
const repo = lockedRepo({ session: SESS_B });
|
|
// a session that holds no lock anywhere stops in a repo another session locked
|
|
const r = await runHook(STOP, stop(repo, { session_id: `sess-free-${RUN}` }));
|
|
assert.equal(parseJsonOut(r.stdout), null);
|
|
});
|
|
|
|
test('P9 stop must pass: no lock', async () => {
|
|
const r = await runHook(STOP, stop(makeRepo('main'), { session_id: `sess-none-${RUN}` }));
|
|
assert.equal(r.code, 0);
|
|
assert.equal(parseJsonOut(r.stdout), null);
|
|
});
|
|
|
|
test('wiring: the write guard covers the lock for Write, Edit, NotebookEdit; the stop guard runs on Stop', () => {
|
|
for (const tool of ['Write', 'Edit', 'NotebookEdit']) {
|
|
assert.ok(scriptsFor('PreToolUse', tool).some((p) => p.endsWith('pre-write-executor.mjs')), tool);
|
|
}
|
|
assert.ok(scriptsFor('Stop').some((p) => p.endsWith('stop-test-lock.mjs')));
|
|
});
|