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:
parent
2c89a6b580
commit
2ee8137e7c
31 changed files with 3278 additions and 107 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue