#!/usr/bin/env node // Hook: pre-write-executor.mjs // Event: PreToolUse (Write) // Purpose: Block writes to security-sensitive paths during plan execution. // // Protocol: // - Read JSON from stdin: { tool_name, tool_input } // - tool_input.file_path — the target path for Write tool // - BLOCK (exit 2): writes to security infrastructure, shell configs, secrets // - Allow (exit 0): everything else import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; 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\//.test(p), description: 'Writing to .git/hooks/ could inject malicious git hooks that execute ' + 'on every commit, push, or checkout. Blocked.', }, { 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.', }, ]; // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- let input; try { const raw = readFileSync(0, 'utf-8'); input = JSON.parse(raw); } catch { // Cannot parse stdin — fail open. process.exit(0); } const filePath = input?.tool_input?.file_path; if (!filePath || typeof filePath !== 'string') { process.exit(0); } 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); } } // Allow process.exit(0);