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

@ -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);