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>
143 lines
5.2 KiB
JavaScript
143 lines
5.2 KiB
JavaScript
// tests/hooks/guard-helpers.mjs
|
|
// Shared helpers for the wave-1 guard tests (P3, P7, P9 lock, P12). Not a test
|
|
// file itself (no `.test.mjs`), so `npm test` does not run it.
|
|
//
|
|
// - Resolves hooks.json the way Claude Code does (matcher semantics:
|
|
// code.claude.com/docs/en/hooks § Matcher patterns), so a wiring gap fails
|
|
// even when a script is correct. Same rules as write-guard-coverage.test.mjs.
|
|
// - Builds throwaway git repos and a throwaway copy of the plugin in which the
|
|
// contract CLIs another wave-1 order owns (K2 approval log, K3 plan gate, K5
|
|
// arch rules) are stubs that record their argv and exit with a chosen code.
|
|
|
|
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, copyFileSync, readdirSync, chmodSync, existsSync, rmSync } from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
export const ROOT = join(HERE, '..', '..');
|
|
export const HOOKS_JSON = join(ROOT, 'hooks', 'hooks.json');
|
|
|
|
// "*", "" or omitted match all; a value of only letters, digits, _, -, spaces,
|
|
// `,` and `|` is a list of exact names; anything else is an unanchored regex.
|
|
export function matcherFires(matcher, name) {
|
|
if (matcher === undefined || matcher === '' || matcher === '*') return true;
|
|
if (/^[A-Za-z0-9_\- ,|]+$/.test(matcher)) {
|
|
return matcher.split(/[|,]/).map((s) => s.trim()).includes(name);
|
|
}
|
|
return new RegExp(matcher).test(name);
|
|
}
|
|
|
|
// Every script hooks.json would run for this event (and tool, when the event
|
|
// has a matcher), with ${CLAUDE_PLUGIN_ROOT} resolved to `root`.
|
|
export function scriptsFor(event, name, root = ROOT) {
|
|
const cfg = JSON.parse(readFileSync(join(root, 'hooks', 'hooks.json'), 'utf8'));
|
|
const out = [];
|
|
for (const entry of cfg.hooks[event] || []) {
|
|
if (name !== undefined && !matcherFires(entry.matcher, name)) continue;
|
|
for (const h of entry.hooks || []) {
|
|
const arg = (h.args || []).find((a) => a.endsWith('.mjs'));
|
|
if (arg) out.push(arg.replace('${CLAUDE_PLUGIN_ROOT}', root));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function tmp(prefix = 'voyage-guard-') {
|
|
return mkdtempSync(join(tmpdir(), prefix));
|
|
}
|
|
|
|
function git(cwd, ...args) {
|
|
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
}
|
|
|
|
// A git repo with one commit on `branch`.
|
|
export function makeRepo(branch = 'main') {
|
|
const dir = tmp('voyage-repo-');
|
|
git(dir, 'init', '-q', '-b', branch);
|
|
git(dir, 'config', 'user.email', 't@example.invalid');
|
|
git(dir, 'config', 'user.name', 'T');
|
|
git(dir, 'config', 'commit.gpgsign', 'false');
|
|
git(dir, 'config', 'tag.gpgsign', 'false');
|
|
writeFileSync(join(dir, 'README.md'), '# t\n');
|
|
git(dir, 'add', 'README.md');
|
|
git(dir, 'commit', '-q', '-m', 'init');
|
|
return dir;
|
|
}
|
|
|
|
export function gitIn(cwd, ...args) {
|
|
return git(cwd, ...args);
|
|
}
|
|
|
|
// A stub CLI: records its argv and cwd as one JSON line in $STUB_LOG, prints
|
|
// $STUB_STDOUT, exits with $STUB_EXIT (default 0).
|
|
const STUB = `#!/usr/bin/env node
|
|
import { appendFileSync } from 'node:fs';
|
|
const log = process.env.STUB_LOG;
|
|
if (log) appendFileSync(log, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }) + '\\n');
|
|
if (process.env.STUB_STDOUT) process.stdout.write(process.env.STUB_STDOUT);
|
|
process.exit(Number(process.env.STUB_EXIT || 0));
|
|
`;
|
|
|
|
function copyTree(src, dst) {
|
|
mkdirSync(dst, { recursive: true });
|
|
for (const e of readdirSync(src, { withFileTypes: true })) {
|
|
const s = join(src, e.name);
|
|
const d = join(dst, e.name);
|
|
if (e.isDirectory()) copyTree(s, d);
|
|
else copyFileSync(s, d);
|
|
}
|
|
}
|
|
|
|
// A copy of the plugin's hooks/ and the lib modules the guards import, with
|
|
// the named contract CLIs replaced by the stub (or left out when `missing`).
|
|
// stubs: { 'lib/util/approval-log.mjs': 'stub' | 'missing', ... }
|
|
export function pluginCopy(stubs = {}) {
|
|
const root = tmp('voyage-plugin-');
|
|
copyTree(join(ROOT, 'hooks'), join(root, 'hooks'));
|
|
copyTree(join(ROOT, 'lib'), join(root, 'lib'));
|
|
for (const [rel, mode] of Object.entries(stubs)) {
|
|
const p = join(root, rel);
|
|
if (mode === 'missing') {
|
|
rmSync(p, { force: true });
|
|
continue;
|
|
}
|
|
mkdirSync(dirname(p), { recursive: true });
|
|
writeFileSync(p, STUB);
|
|
}
|
|
return root;
|
|
}
|
|
|
|
export function readStubLog(path) {
|
|
if (!existsSync(path)) return [];
|
|
return readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
}
|
|
|
|
// An executable shell stub at <dir>/<name>: exits 1 and prints a line when any
|
|
// file argument contains the text BAD, else exits 0. Used as a fake formatter
|
|
// or linter so the post-edit check can be shown to stop AND to pass.
|
|
export function fakeTool(dir, name, { findingExit = 1 } = {}) {
|
|
mkdirSync(dir, { recursive: true });
|
|
const p = join(dir, name);
|
|
writeFileSync(
|
|
p,
|
|
`#!/bin/sh
|
|
found=0
|
|
for a in "$@"; do
|
|
case "$a" in -*) continue ;; esac
|
|
if [ -f "$a" ] && grep -q BAD "$a"; then echo "${name}: problem in $a"; found=1; fi
|
|
done
|
|
if [ "$found" = 1 ]; then exit ${findingExit}; fi
|
|
exit 0
|
|
`,
|
|
);
|
|
chmodSync(p, 0o755);
|
|
return p;
|
|
}
|
|
|
|
export function parseJsonOut(stdout) {
|
|
const s = String(stdout || '').trim();
|
|
if (!s) return null;
|
|
return JSON.parse(s);
|
|
}
|