voyage/hooks/scripts/pre-write-executor.mjs
Kjell Tore Guttormsen 2ee8137e7c
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>
2026-10-06 20:09:32 +02:00

217 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
// Hook: pre-write-executor.mjs
// Event: PreToolUse (Write, Edit, NotebookEdit)
// Purpose: Block writes to security-sensitive paths. Wired universally (every
// Voyage session, not just execution) by deliberate decision — protecting
// ~/.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 }
// - Target path: tool_input.file_path (Write, Edit) or
// 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';
// ---------------------------------------------------------------------------
// BLOCK rules — path patterns that must never be written during execution.
// ---------------------------------------------------------------------------
const BLOCK_RULES = [
{
name: 'Git hook injection (.git/hooks/)',
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),
description:
'Writing to .claude/settings.json could disable security hooks or ' +
'change permission modes. Blocked.',
},
{
name: 'Claude hooks self-modification',
test: (p) => /\/\.claude\/hooks\//.test(p) || /\/\.claude-plugin\//.test(p),
description:
'Writing to .claude/hooks/ or .claude-plugin/ could modify security ' +
'hook configuration. Blocked.',
},
{
name: 'Shell configuration files',
test: (p) => {
const sensitive = [
`${HOME}/.zshrc`,
`${HOME}/.bashrc`,
`${HOME}/.bash_profile`,
`${HOME}/.profile`,
`${HOME}/.zshenv`,
`${HOME}/.zprofile`,
];
const resolved = resolve(p);
return sensitive.some((s) => resolved === s || resolved.startsWith(s + '.'));
},
description:
'Writing to shell config files (~/.zshrc, ~/.bashrc, etc.) could inject ' +
'persistent commands. Blocked.',
},
{
name: 'SSH directory',
test: (p) => {
const resolved = resolve(p);
return resolved.startsWith(`${HOME}/.ssh/`) || resolved === `${HOME}/.ssh`;
},
description: 'Writing to ~/.ssh/ could compromise SSH keys or config. Blocked.',
},
{
name: 'AWS credentials',
test: (p) => {
const resolved = resolve(p);
return resolved.startsWith(`${HOME}/.aws/`) || resolved === `${HOME}/.aws`;
},
description: 'Writing to ~/.aws/ could compromise cloud credentials. Blocked.',
},
{
name: 'GnuPG directory',
test: (p) => {
const resolved = resolve(p);
return resolved.startsWith(`${HOME}/.gnupg/`) || resolved === `${HOME}/.gnupg`;
},
description: 'Writing to ~/.gnupg/ could compromise GPG keys. Blocked.',
},
{
name: 'Environment files (.env)',
test: (p) => /\/\.env(?:\.[a-zA-Z0-9]+)?$/.test(p),
description:
'Writing to .env files could expose or modify secrets. Blocked. ' +
'Use .env.template instead.',
},
];
// Which tool_input field carries the target path, per tool. Write and Edit:
// hooks reference, PreToolUse tool_input tables. NotebookEdit: the tool's own
// input schema. hooks.json must wire exactly these tools.
const PATH_FIELD = {
Write: 'file_path',
Edit: 'file_path',
NotebookEdit: 'notebook_path',
};
function blockNoPath(reason) {
process.stderr.write(
`[voyage] BLOCKED: no target path (${reason})\n` +
' The write guard could not read the path this call writes to, so it ' +
'cannot be checked. Blocked (fail closed).\n'
);
process.exit(2);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
let input;
try {
const raw = readFileSync(0, 'utf-8');
input = JSON.parse(raw);
} catch {
blockNoPath('unreadable hook input');
}
const field = PATH_FIELD[input?.tool_name];
if (!field) {
blockNoPath(`unknown tool ${JSON.stringify(input?.tool_name)}`);
}
const filePath = input?.tool_input?.[field];
if (!filePath || typeof filePath !== 'string') {
blockNoPath(`${input.tool_name} without tool_input.${field}`);
}
const resolved = resolve(filePath);
for (const rule of BLOCK_RULES) {
if (rule.test(resolved)) {
process.stderr.write(
`[voyage] BLOCKED: ${rule.name}\n` +
` Path: ${resolved}\n` +
` ${rule.description}\n`
);
process.exit(2);
}
}
// 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);