feat(hooks): wave-1 guards -- approval ask, plan gate, test lock, post-edit check

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>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 20:09:32 +02:00
commit 2ee8137e7c
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
31 changed files with 3278 additions and 107 deletions

View file

@ -0,0 +1,89 @@
#!/usr/bin/env node
// Hook: post-approval-log.mjs
// Event: PostToolUse (Bash, ExitPlanMode)
// Purpose: write an approval a person gave through Claude Code's prompt into
// the approval log (contract K2, lib/util/approval-log.mjs).
// - Bash: the call ran, so the ask from pre-bash-executor.mjs was approved.
// What is logged is the record that hook wrote of what the person was
// asked (hooks/lib/approval-record.mjs); no record, nothing logged.
// Merge/push to main is logged as `merge`, tag/publish as `release`, each
// with `--expires` (APPROVAL_TTL_MS). Push of other branches and
// destructive git are not logged (operator decision 06.10). In
// bypassPermissions mode nothing is logged: no person was asked.
// sha256 binds the approval to the command and the commit it left HEAD on.
// - ExitPlanMode: the person approved a plan. A Voyage plan (it carries
// `plan_version`) is logged as `plan`, bound to the sha256 of the approved
// text (`tool_response.plan`, hooks reference section ExitPlanMode).
// The guard never blocks (PostToolUse cannot), and it is never silent about a
// lost approval: when K2 is missing, refuses (1) or cannot run (2), Claude is
// told "not logged".
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { APPROVAL_TTL_MS } from '../lib/git-approval.mjs';
import { takeApprovalRecord } from '../lib/approval-record.mjs';
import { runContract, isVoyagePlan } from '../lib/contract-cli.mjs';
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
function say(text) {
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text } }));
}
function headOf(cwd) {
const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', timeout: 10_000 });
return r.status === 0 ? r.stdout.trim() : '';
}
function append(kind, subject, hash, cwd, expires) {
const args = ['append', '--kind', kind, '--subject', subject, '--sha256', hash];
if (expires) args.push('--expires', expires);
const r = runContract('K2', args, { cwd });
if (!r.ran) return `${kind} approval not logged: ${r.why}`;
if (r.code === 0) return `${kind} approval logged${expires ? ` (expires ${expires})` : ''}`;
const why = (r.stderr || r.stdout).trim().split('\n')[0] || `exit ${r.code}`;
return `${kind} approval not logged: the approval log ${r.code === 1 ? 'refused it' : 'could not run'} (${why})`;
}
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
process.exit(0);
}
const cwd = input?.cwd || process.cwd();
if (input?.tool_name === 'ExitPlanMode') {
const plan = input?.tool_response?.plan ?? input?.tool_input?.plan;
if (!isVoyagePlan(plan)) process.exit(0);
const subject = input?.tool_response?.filePath || input?.tool_input?.planFilePath || '(plan without a file path)';
say(`[voyage] ${append('plan', subject, sha256(plan), cwd)}.`);
process.exit(0);
}
if (input?.tool_name === 'Bash') {
// Log what the person was asked (pre-bash-executor.mjs wrote it), nothing else.
const rec = takeApprovalRecord(input?.session_id, input?.tool_use_id);
const matches = (rec?.matches || []).filter((m) => m.kind);
if (matches.length === 0) process.exit(0);
if (rec.permission_mode === 'bypassPermissions') {
say('[voyage] approval not logged: the session runs in bypassPermissions mode, so no person was asked.');
process.exit(0);
}
const expires = new Date(Date.now() + APPROVAL_TTL_MS).toISOString();
const seen = new Set();
const notes = [];
for (const m of matches) {
const key = `${m.kind}\n${m.subject}`;
if (seen.has(key)) continue;
seen.add(key);
const subject = m.subject || cwd;
const hash = sha256(`${rec.command}\n${headOf(subject)}`);
notes.push(append(m.kind, subject, hash, cwd, expires));
}
say(`[voyage] ${notes.join('; ')}.`);
}
process.exit(0);

View file

@ -0,0 +1,90 @@
#!/usr/bin/env node
// Hook: post-edit-check.mjs
// Event: PostToolUse (Edit, Write)
// Purpose: P7 -- after each edit, run the target repo's OWN formatter and
// linter on the edited file (the fast tier of three: per file here / step
// end and full in lib/verification/step-check.mjs, contract K9). Findings go
// back to Claude next to the tool result (`decision: "block"`, hooks
// reference section PostToolUse decision control); the edit itself already
// happened, so the stop is at step end, where K9 exits 1 on them.
// A file no configured, installed tool covers is "not measured" -- never
// clean. Every result is logged in <git dir>/voyage/post-edit.jsonl for K9.
//
// On only while Voyage executes a plan in the repo, or when the repo has
// switched it on (hooks/lib/post-edit.mjs; operator decision 06.10). In every
// other session it exits at once and says nothing.
//
// Credit: running the formatter after Edit|Write is Anthropic's own documented
// hook pattern (hooks-guide); formatting per edit and the rest at step end is
// Cherny's; the cost tiers and "not measured" are agent-skills' (MIT); the
// per-file / step-end split is Kiro's. Ideas only.
import { readFileSync } from 'node:fs';
import { relative } from 'node:path';
import { resolveRepo, detectTools, toolsForFile, runTool, canonicalPath } from '../../lib/verification/repo-tools.mjs';
import { postEditActive, appendPostEditLog } from '../lib/post-edit.mjs';
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
process.exit(0);
}
const file = input?.tool_input?.file_path;
if (!file || typeof file !== 'string') process.exit(0);
const repo = resolveRepo(file);
if (!repo) process.exit(0);
const why = postEditActive(repo.root);
if (!why) process.exit(0);
const rel = relative(repo.root, canonicalPath(file));
const all = detectTools(repo.root);
const tools = toolsForFile(all, file);
const record = { ts: new Date().toISOString(), file: rel, on: why, session: input?.session_id ?? null };
function out(obj) {
process.stdout.write(JSON.stringify(obj));
process.exit(0);
}
if (tools.length === 0) {
const missing = all.filter((t) => !t.available).map((t) => t.reason);
appendPostEditLog(repo.gitDir, { ...record, status: 'not_measured', results: [], reason: missing.join('; ') || 'no tool for this file type' });
out({
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext:
`[voyage] post-edit check: not measured for ${rel} -- no formatter or linter for this file type is ` +
`both configured and installed in this repository${missing.length ? ` (${missing.join('; ')})` : ''}.`,
},
});
}
const results = tools.map((t) => runTool(t, [rel], { cwd: repo.root, timeout: 30_000 }));
const findings = results.filter((r) => r.status === 'finding');
const errors = results.filter((r) => r.status === 'error');
const status = findings.length ? 'finding' : errors.length ? 'not_measured' : 'clean';
appendPostEditLog(repo.gitDir, { ...record, status, results: results.map(({ output, ...r }) => r) });
const errText = errors.length ? ` Not measured: ${errors.map((e) => `${e.name} could not run (exit ${e.exit})`).join('; ')}.` : '';
if (findings.length) {
out({
decision: 'block',
reason:
`[voyage] post-edit check found problems in ${rel}: ` +
findings.map((f) => `${f.name} (${f.kind}): ${f.output.split('\n').slice(0, 15).join('\n')}`).join('\n') +
`\nFix them before the step ends; the step check fails on them.${errText}`,
});
}
out({
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: status === 'clean'
? `[voyage] post-edit check clean for ${rel}: ${results.map((r) => r.name).join(', ')}.`
: `[voyage] post-edit check for ${rel}:${errText}`,
},
});

View file

@ -1,8 +1,16 @@
#!/usr/bin/env node
// Hook: pre-agent-cap.mjs
// Event: PreToolUse (WebSearch | WebFetch | Task)
// Event: PreToolUse (WebSearch | WebFetch | Task | Agent)
// Purpose: Enforce the /trekresearch Phase 5 loop bound at the harness level,
// so the cap is a reader that fells rather than prose the model obeys.
// The subagent tool is `Agent` since Claude Code 2.1.63; the docs keep `Task`
// as an alias only "in settings and agent definitions", so the matcher names
// both (tests/hooks/storm-matcher.test.mjs).
//
// Precondition: Voyage's guards are deterministic only when no installed mod
// handles `tool.check`. A mod that does answers after the PreToolUse hooks
// and can replace their answer (code.claude.com/docs/en/permissions section Extend
// permissions with hooks). A hook cannot see whether such a mod is installed.
//
// Why this exists: the Phase 5 budget gate (lib/util/research-loop-cap.mjs) is
// invoked BY the loop. A gate the caller chooses to consult is advice. The

View file

@ -1,51 +1,51 @@
#!/usr/bin/env node
// Hook: pre-bash-executor.mjs
// Event: PreToolUse (Bash)
// Purpose: Block or warn about destructive shell commands. Wired universally
// (every Voyage session, not just execution) by deliberate decision — these
// are session-agnostic safety rails (rm -rf /, fork bombs, …). The CC if:
// path-scoping mechanism now works, but narrowing to execute-only would only
// weaken protection with no offsetting benefit. See cc-upgrade matrix CC-15/F2.
// Purpose: Block or warn about destructive shell commands, and ask a person
// before merge into main, push, tag/release and destructive git (P12).
// Wired universally (every Voyage session, not just execution) by deliberate
// decision -- these are session-agnostic safety rails (rm -rf /, fork bombs,
// ...). The CC if: path-scoping mechanism now works, but narrowing to
// execute-only would only weaken protection with no offsetting benefit. See
// cc-upgrade matrix CC-15/F2.
//
// Precondition: Voyage's guards are deterministic only when no installed mod
// handles `tool.check`. A mod that does answers after the PreToolUse hooks
// and can replace their answer (code.claude.com/docs/en/permissions section Extend
// permissions with hooks). A hook cannot see whether such a mod is installed.
//
// Protocol:
// - Read JSON from stdin: { tool_name, tool_input }
// - Read JSON from stdin: { tool_name, tool_input, cwd }
// - tool_input.command — the shell command string
// - BLOCK (exit 2): catastrophic/irreversible operations
// - ASK (exit 0, JSON permissionDecision "ask"): merge into main, push,
// tag/release, destructive git (hooks/lib/git-approval.mjs). The prompt is
// labelled [plugin:voyage] and also appears in auto mode (hooks reference
// section PreToolUse decision control). Advisories are added to the prompt.
// - WARN (exit 0): risky but recoverable operations — advisory to stderr
// - Allow (exit 0): everything else
//
// Based on llm-security's pre-bash-destructive.mjs with executor-specific additions.
// bash-normalize logic copied inline (MIT) — cannot import from separate plugin.
// Command reading lives in hooks/lib/bash-view.mjs (shared with the hook that
// logs approvals).
import { readFileSync } from 'node:fs';
// ---------------------------------------------------------------------------
// Bash normalization (from llm-security/scanners/lib/bash-normalize.mjs)
// Strips bash evasion techniques: empty quotes, ${} expansion, backslash splitting.
// ---------------------------------------------------------------------------
function normalizeBashExpansion(cmd) {
if (!cmd || typeof cmd !== 'string') return cmd || '';
let result = cmd
// Strip empty single quotes: w''get -> wget
.replace(/''/g, '')
// Strip empty double quotes: r""m -> rm
.replace(/""/g, '')
// Single-char ${x} -> x (evasion: c${u}rl -> curl, assumes x=x)
.replace(/\$\{(\w)\}/g, '$1')
// Multi-char ${ANYTHING} -> '' (unknown value, strip entirely)
.replace(/\$\{[^}]*\}/g, '')
// Strip backtick subshell with empty/whitespace content
.replace(/`\s*`/g, '');
// Iteratively strip backslash between word chars (c\u\r\l needs 2 passes)
let prev;
do {
prev = result;
result = result.replace(/(\w)\\(\w)/g, '$1$2');
} while (result !== prev);
return result;
// The guard's own modules are loaded so that a broken install cannot crash
// the hook: a crash (exit 1) is a non-blocking error to Claude Code, which
// would let every command through. A module that will not load makes the
// guard ask about every command instead.
let lib;
let loadError = null;
try {
const [view, approval, record] = await Promise.all([
import('../lib/bash-view.mjs'),
import('../lib/git-approval.mjs'),
import('../lib/approval-record.mjs'),
]);
lib = { ...view, ...approval, ...record };
} catch (e) {
loadError = e;
}
// ---------------------------------------------------------------------------
@ -197,69 +197,6 @@ const WARN_RULES = [
},
];
// ---------------------------------------------------------------------------
// Normalize: strip ANSI, collapse whitespace
// ---------------------------------------------------------------------------
function normalizeCommand(cmd) {
return cmd
.replace(/\x1B\[[0-9;]*m/g, '')
.replace(/\s+/g, ' ')
.trim();
}
// ---------------------------------------------------------------------------
// Command-position view — for rules that must distinguish a command from data
// that merely names one. Whitespace is NOT collapsed, so newline stays a
// separator. Quoted spans and heredoc bodies become data; the argument of a
// shell wrapper stays a command; backslash-escaped names are seen through.
// ---------------------------------------------------------------------------
// `out` ends with the `-c` of a shell invocation — the next quoted span is a
// command string, not data. Optional leading `sudo` and absolute path.
const SHELL_C_TAIL =
/(?:^|[\s;|&])(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:ba|z|k|da|a)?sh\s+(?:-[a-zA-Z]+\s+)*-c\s*$/;
// Drop heredoc bodies, keeping the operator line. Their newlines are not
// command separators, and without this every heredoc line that happens to
// start with a matched word reads as command position. Runs before the quote
// scan, since a body may contain quotes that would desync it.
function stripHeredocBodies(cmd) {
return cmd.replace(
/(<<-?\s*(['"]?)(\w+)\2[^\n]*\n)[\s\S]*?(?:\n[ \t]*\3[ \t]*(?=\n|$)|$)/g,
(_match, head) => head,
);
}
function commandPositionView(cmd) {
const src = stripHeredocBodies(cmd);
let out = '';
let i = 0;
while (i < src.length) {
const ch = src[i];
if (ch === "'" || ch === '"') {
const close = src.indexOf(ch, i + 1);
const inner = close === -1 ? src.slice(i + 1) : src.slice(i + 1, close);
// Unterminated quote — treat the remainder as one span and stop.
out += SHELL_C_TAIL.test(out) ? `;${inner};` : ' ';
i = close === -1 ? src.length : close + 1;
} else {
out += ch;
i += 1;
}
}
return (
out
// `xargs [flags] <cmd>` puts <cmd> at command position with no separator
// in front of it. Flags taking a separate argument (`-I {}`) are not
// parsed — the separator lands before the argument, not the command.
.replace(/\bxargs((?:\s+-[a-zA-Z0-9-]+)*)/g, 'xargs$1 ;')
// `\name` runs name — the backslash only suppresses alias expansion.
// normalizeBashExpansion covers the between-word-chars case; this covers
// a backslash at command position.
.replace(/\\(\w)/g, '$1')
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
@ -278,6 +215,20 @@ if (!command || typeof command !== 'string') {
process.exit(0);
}
if (loadError) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'ask',
permissionDecisionReason:
`Voyage's Bash guard could not load its rules (${loadError.message}), so this command was not checked. ` +
'Way forward: approve only a command you have read yourself; reinstall Voyage to restore the guard.',
},
}));
process.exit(0);
}
const { normalizeBashExpansion, normalizeCommand, commandPositionView, classifyGitApproval, askReason, writeApprovalRecord } = lib;
// Strip bash evasion, then normalize whitespace
const deobfuscated = normalizeBashExpansion(command);
const normalized = normalizeCommand(deobfuscated);
@ -311,4 +262,35 @@ if (warnings.length > 0) {
);
}
// P12: ask a person before merge into main, push, tag/release, destructive git.
// A classifier error must not turn into a silent allow of a push: ask instead.
let approvals;
try {
approvals = classifyGitApproval(command, { cwd: input.cwd });
} catch (e) {
approvals = [{ name: `a command the approval guard could not read (${e.message})`, kind: null, way: 'approve only if you have read the command yourself.' }];
}
if (approvals.length > 0) {
// What the person is asked is what post-approval-log.mjs logs once the call ran.
const loggable = approvals.filter((m) => m.kind).map(({ kind, name, subject }) => ({ kind, name, subject }));
const recorded = writeApprovalRecord(input.session_id, input.tool_use_id, {
command,
permission_mode: input.permission_mode ?? null,
matches: loggable,
asked_at: new Date().toISOString(),
});
// Say it now if this approval cannot reach the log afterwards.
const unlogged = loggable.length > 0 && !recorded
? ' Note: this approval will not be logged (Voyage could not record the question).' : '';
const advisory = (warnings.length > 0 ? ` Advisory: ${warnings.map((w) => w.trim()).join(' ')}` : '') + unlogged;
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'ask',
permissionDecisionReason: askReason(approvals) + advisory,
},
}));
}
process.exit(0);

View file

@ -0,0 +1,71 @@
#!/usr/bin/env node
// Hook: pre-plan-gate.mjs
// Event: PreToolUse (ExitPlanMode)
// Purpose: P3 -- wrap Claude Code's plan approval. Claude Code hands this hook
// the plan text and the plan file path (hooks reference section ExitPlanMode:
// "Claude Code injects the plan content and file path before passing the
// input to hooks"). For a Voyage plan (it carries `plan_version`) the hook
// runs Voyage's plan gate (contract K3, lib/validators/plan-gate.mjs) on that
// file and sends a plan the gate refuses back to planning. Every other plan
// passes untouched: the hook runs in every session.
//
// Precondition: Voyage's guards are deterministic only when no installed mod
// handles `tool.check`. A mod that does answers after the PreToolUse hooks
// and can replace their answer (code.claude.com/docs/en/permissions section Extend
// permissions with hooks). A hook cannot see whether such a mod is installed.
//
// Answers:
// - K3 exit 0 -> exit 0, no output (Claude Code's own approval follows)
// - K3 exit 1 -> exit 2 (deny): the gate's words go to Claude
// - K3 exit 2 / missing / no plan file -> "ask" with "not measured": never a
// silent pass, and not a refusal of a plan nobody could check
// Credit: wrapping plan mode is Claude Code's own pattern (P3 analysis).
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { runContract, isVoyagePlan } from '../lib/contract-cli.mjs';
function ask(reason) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'ask',
permissionDecisionReason: `Voyage plan gate: not measured (${reason}). Approve only if you have checked the plan yourself.`,
},
}));
process.exit(0);
}
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
// Unreadable input: nothing to judge, and no plan to call Voyage's.
process.exit(0);
}
const plan = input?.tool_input?.plan;
if (!isVoyagePlan(plan)) process.exit(0);
const cwd = input?.cwd || process.cwd();
const given = input?.tool_input?.planFilePath;
// Plan paths are absolute in practice; a relative one is read from the
// session's directory, the same one the gate runs in.
const planPath = typeof given === 'string' && given ? resolve(cwd, given) : null;
if (!planPath || !existsSync(planPath)) {
ask('Claude Code attached no readable plan file');
}
const r = runContract('K3', [planPath], { cwd });
if (!r.ran) ask(r.why);
if (r.code === 0) process.exit(0);
if (r.code === 1) {
const words = (r.stdout + r.stderr).trim().slice(0, 4000) || 'the plan gate refused the plan';
process.stderr.write(
'[voyage] Plan sent back: Voyage\'s plan gate refused this plan.\n' +
`${words}\n` +
'Way forward: fix what the gate names, or plan again; then ask for approval again.\n'
);
process.exit(2);
}
ask(`the plan gate exited ${r.code}`);

View file

@ -6,6 +6,12 @@
// ~/.ssh, ~/.aws, .git/hooks, .env, shell configs benefits every session.
// The CC if: path-scoping mechanism now works, but narrowing to execute-only
// would only weaken protection with no benefit. See cc-upgrade matrix CC-15/F2.
// Also enforces the test lock (P9, contract K8, lib/util/test-lock.mjs).
//
// Precondition: Voyage's guards are deterministic only when no installed mod
// handles `tool.check`. A mod that does answers after the PreToolUse hooks
// and can replace their answer (code.claude.com/docs/en/permissions section Extend
// permissions with hooks). A hook cannot see whether such a mod is installed.
//
// Protocol:
// - Read JSON from stdin: { tool_name, tool_input }
@ -13,11 +19,26 @@
// tool_input.notebook_path (NotebookEdit)
// - BLOCK (exit 2): writes to security infrastructure, shell configs, secrets
// - BLOCK (exit 2): no readable target path (fail closed)
// - BLOCK (exit 2): a test file the current step has locked (K8), and any
// write in a repo whose lock cannot be read (fail closed)
// - Allow (exit 0): everything else
// Bash can still rewrite a locked file (sed -i, >); this guard covers the
// file tools only.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// The lock code is loaded so that a broken install cannot crash this hook (a
// crash is a non-blocking error: every write would pass, the path rules
// included). A lock module that will not load blocks the write instead.
let lockLib = null;
let lockLoadError = null;
try {
lockLib = await import('../../lib/util/test-lock.mjs');
} catch (e) {
lockLoadError = e;
}
const HOME = process.env.HOME || process.env.USERPROFILE || '/tmp';
// ---------------------------------------------------------------------------
@ -26,11 +47,20 @@ const HOME = process.env.HOME || process.env.USERPROFILE || '/tmp';
const BLOCK_RULES = [
{
name: 'Git hook injection (.git/hooks/)',
test: (p) => /\/\.git\/hooks\//.test(p),
test: (p) => /\/\.git\/hooks\//i.test(p),
description:
'Writing to .git/hooks/ could inject malicious git hooks that execute ' +
'on every commit, push, or checkout. Blocked.',
},
{
name: "Voyage's own guard state (.git/voyage/)",
// The test lock and the post-edit log. A worktree's git dir is
// .git/worktrees/<name>/, so that form is covered too.
test: (p) => /\/\.git\/(?:worktrees\/[^/]+\/)?voyage(?:\/|$)/i.test(p),
description:
'Writing to the git directory\'s voyage/ folder would unlock a locked test or ' +
'rewrite the post-edit log. Blocked; the executor and the operator manage it.',
},
{
name: 'Claude settings self-modification',
test: (p) => /\/\.claude\/settings[^/]*\.json$/.test(p),
@ -149,5 +179,39 @@ for (const rule of BLOCK_RULES) {
}
}
// P9 test lock (K8).
if (lockLoadError) {
process.stderr.write(
'[voyage] BLOCKED: the test-lock guard could not load\n' +
` ${lockLoadError.message}\n` +
' Without it no write can be checked against a locked test. Blocked (fail ' +
'closed). Reinstall Voyage to restore the guard.\n'
);
process.exit(2);
}
const { readLock, isLockedFile } = lockLib;
const lock = readLock(resolved);
if (lock.state === 'corrupt') {
process.stderr.write(
'[voyage] BLOCKED: unreadable test lock\n' +
` Lock: ${lock.path} (${lock.error})\n` +
' A step locked its tests, but the lock cannot be read, so no write in this ' +
'repository can be checked against it. Blocked (fail closed). Ask the operator ' +
'to inspect the lock file.\n'
);
process.exit(2);
}
if (lock.state === 'locked' && isLockedFile(lock.lock, resolved)) {
process.stderr.write(
`[voyage] BLOCKED: locked test file (step ${lock.lock.step})\n` +
` Path: ${resolved}\n` +
` Step ${lock.lock.step} locked this test after it was seen failing; it must pass ` +
'unchanged. Way forward: change the code under test, not the test. If the test ' +
'itself is wrong, stop and tell the operator; only the operator or the executor ' +
'unlocks it.\n'
);
process.exit(2);
}
// Allow
process.exit(0);

View file

@ -0,0 +1,50 @@
#!/usr/bin/env node
// Hook: stop-test-lock.mjs
// Event: Stop
// Purpose: P9 (3) -- "done" is refused while a step's tests are locked (K8,
// lib/util/test-lock.mjs). The executor unlocks a step only after its test
// passes, so a held lock means the step's test has not been seen passing.
//
// Scope: only the session that took the lock is held (the lock records
// CLAUDE_CODE_SESSION_ID, which the docs say matches the hook input's
// session_id: code.claude.com/docs/en/env-vars). Any other session
// in the same repository stops freely, so a lock left behind by a crash
// cannot trap unrelated work.
// Loop guard: when stop_hook_active is true, Claude Code is already continuing
// because of a stop hook; this hook then lets the stop through
// (code.claude.com/docs/en/hooks section Stop input; hooks-guide "Stop hook hits
// the block cap").
// TaskCompleted is not used: it fires on the TaskUpdate tool, which current
// models do not get by default (tools-reference section Task tool availability).
//
// Credit: refusing "done" until the check passes is Claude Code's own
// documented pattern (hooks reference section TaskCompleted / Stop decision control).
import { readFileSync } from 'node:fs';
import { readLock, lockForSession } from '../../lib/util/test-lock.mjs';
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
process.exit(0);
}
if (input?.stop_hook_active === true) process.exit(0);
// The lock in the session's current repo, else the one this session took
// elsewhere (the session may have moved to another directory since).
let lock = readLock(input?.cwd || process.cwd());
if (lock.state !== 'locked' || lock.lock.session !== input?.session_id) lock = lockForSession(input?.session_id);
if (lock.state !== 'locked') process.exit(0);
if (!lock.lock.session || lock.lock.session !== input?.session_id) process.exit(0);
const files = lock.lock.files.join(', ');
process.stdout.write(JSON.stringify({
decision: 'block',
reason:
`[voyage] Not done: step ${lock.lock.step} still holds its test lock (${files}). ` +
"The step is done when its test passes and the executor unlocks it. Run the step's " +
'test; if it passes, finish the step. If it cannot pass, say so plainly and stop.',
}));
process.exit(0);