#!/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//, 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);