#!/usr/bin/env node // Hook: pre-write-pathguard.mjs // Event: PreToolUse (Write) // Purpose: Block writes to sensitive paths (.env, .ssh/, .aws/, credentials, etc.) // // Protocol: // - Read JSON from stdin: { tool_name, tool_input } // - tool_input.file_path — destination path // - Block: stderr + exit 2 // - Allow: exit 0 import { readFileSync } from 'node:fs'; import { basename, normalize, resolve } from 'node:path'; import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; // --------------------------------------------------------------------------- // Sensitive path patterns — 8 categories // --------------------------------------------------------------------------- /** Category 1: Environment files * Matches `.env` and any multi-segment suffix after it, e.g. * `.env.local`, `.env.production.local.backup`, `.env.stage-1.local`, * `.env.CI.secret`. Does NOT match `.envrc` (direnv) — no dot separator. * v7.1.0 B1 fix: previous regex `/[\\/]\.env\.[a-z]+$/` only matched a * single lowercase segment after `.env`. */ const ENV_PATTERNS = [ /[\\/]\.env(\.[A-Za-z0-9._-]+)*$/, ]; /** Category 2: SSH directory */ const SSH_PATTERNS = [ /[\\/]\.ssh[\\/]/, ]; /** Category 3: AWS credentials */ const AWS_PATTERNS = [ /[\\/]\.aws[\\/]/, ]; /** Category 4: GPG directory */ const GPG_PATTERNS = [ /[\\/]\.gnupg[\\/]/, ]; /** Category 5: Credential files */ const CREDENTIAL_FILES = [ '.npmrc', '.pypirc', '.netrc', '.docker/config.json', 'credentials.json', 'service-account.json', 'keyfile.json', ]; /** Category 6: Hook scripts (prevent hook tampering) */ const HOOK_PATTERNS = [ /[\\/]\.claude[\\/].*hooks.*\.json$/, /[\\/]hooks[\\/]scripts[\\/].*\.mjs$/, ]; /** Category 7: System directories */ const SYSTEM_PATTERNS = [ /^\/etc[\\/]/, /^\/usr[\\/]/, /^\/var[\\/]/, ]; /** Category 8: Settings files */ const SETTINGS_FILES = [ 'settings.json', 'settings.local.json', ]; /** Category 9: Policy-defined additional protected paths */ const POLICY_PATTERNS = getPolicyValue('pathguard', 'additional_protected', []).map(p => new RegExp(p)); // --------------------------------------------------------------------------- // Path classification // --------------------------------------------------------------------------- /** * Check if a file path targets a sensitive location. * @param {string} filePath - The path to check * @returns {{ blocked: boolean, category: string, reason: string }} */ function classifyPath(filePath) { if (!filePath) return { blocked: false, category: '', reason: '' }; const norm = normalize(resolve(filePath)); const base = basename(norm); // Category 1: Environment files for (const pat of ENV_PATTERNS) { if (pat.test(norm)) { return { blocked: true, category: 'env', reason: `Environment file: ${base}` }; } } // Category 2: SSH for (const pat of SSH_PATTERNS) { if (pat.test(norm)) { return { blocked: true, category: 'ssh', reason: `SSH directory: ${norm}` }; } } // Category 3: AWS for (const pat of AWS_PATTERNS) { if (pat.test(norm)) { return { blocked: true, category: 'aws', reason: `AWS credentials directory: ${norm}` }; } } // Category 4: GPG for (const pat of GPG_PATTERNS) { if (pat.test(norm)) { return { blocked: true, category: 'gnupg', reason: `GPG directory: ${norm}` }; } } // Category 5: Credential files for (const name of CREDENTIAL_FILES) { if (norm.endsWith(name) || base === name) { return { blocked: true, category: 'credentials', reason: `Credential file: ${base}` }; } } // Category 6: Hook scripts for (const pat of HOOK_PATTERNS) { if (pat.test(norm)) { return { blocked: true, category: 'hooks', reason: `Hook configuration: ${base}` }; } } // Category 7: System directories for (const pat of SYSTEM_PATTERNS) { if (pat.test(norm)) { return { blocked: true, category: 'system', reason: `System directory: ${norm}` }; } } // Category 8: Settings files for (const name of SETTINGS_FILES) { if (base === name) { // Only block settings.json in .claude/ directories if (/[\\/]\.claude[\\/]/.test(norm) || /[\\/]\.vscode[\\/]/.test(norm)) { return { blocked: true, category: 'settings', reason: `Settings file: ${norm}` }; } } } // Category 9: Policy-defined additional protected paths for (const pat of POLICY_PATTERNS) { if (pat.test(norm)) { return { blocked: true, category: 'policy', reason: `Policy-protected path: ${norm}` }; } } return { blocked: false, category: '', reason: '' }; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- let input; try { const raw = readFileSync(0, 'utf-8'); input = JSON.parse(raw); } catch { process.exit(0); } const toolInput = input?.tool_input ?? {}; const filePath = toolInput.file_path ?? ''; if (!filePath) { process.exit(0); } const result = classifyPath(filePath); if (result.blocked) { process.stderr.write( `\n[llm-security] PATH GUARD: Write blocked\n` + ` Category: ${result.category}\n` + ` Reason: ${result.reason}\n` + ` Path: ${filePath}\n\n` + `This path is protected. If this write is intentional, ` + `ask the user to perform it manually.\n` ); process.exit(2); } process.exit(0);