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>
217 lines
7.6 KiB
JavaScript
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);
|