#!/usr/bin/env node /** * PreToolUse Hook: Security guard layer (cross-platform version) * * Event: PreToolUse * Two-tier security: Block (exit 2) | Confirm (exit 0 + ask user) */ const path = require('path'); const os = require('os'); const common = require('./hook-common'); // Read stdin input let input = {}; try { const stdinData = require('fs').readFileSync(0, 'utf8'); if (stdinData.trim()) { input = JSON.parse(stdinData); } } catch { // Use default empty object } const toolName = input.tool_name || ''; const cwd = input.cwd || process.cwd(); let decision = 'allow'; let reason = ''; let confirmLabel = ''; function isPathInside(basePath, targetPath) { const relative = path.relative(basePath, targetPath); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } // === Bash command security check === if (toolName === 'Bash') { const command = input.tool_input?.command || ''; // Tier 1: Block — catastrophic, no recovery const blockPatterns = [ /rm\s+-rf\s+\/(\s|$)/, // rm -rf / /rm\s+--no-preserve-root/, // rm --no-preserve-root /dd\s+if=\/dev\/(zero|random)/, // dd from /dev/zero or /dev/random />\s*\/dev\/(sd|nvme|vda)/, // Write to block devices /mkfs\./, // Format filesystem /rm\s+-rf?\s+\/(etc|usr|bin|sbin)(\/|\s|$)/, // Remove system dirs /rm\s+-rf\s+\/home\/[^\/\s]*\/?(\s|$)/, // Remove user home dir /rm\s+-rf\s+\/Users\/[^\/\s]*\/?(\s|$)/, // Remove macOS user dir ]; for (const pattern of blockPatterns) { if (pattern.test(command)) { decision = 'deny'; reason = 'Catastrophic command detected'; break; } } // Tier 2: Confirm — dangerous but sometimes legitimate if (decision === 'allow') { const confirmPatterns = [ { pattern: /git\s+push\s+.*(-f|--force)/, label: 'git push --force (overwrites remote history)' }, { pattern: /git\s+reset\s+--hard/, label: 'git reset --hard (discards all uncommitted changes)' }, { pattern: /git\s+clean\s+-[a-z]*f/, label: 'git clean -f (permanently deletes untracked files)' }, { pattern: /git\s+(checkout|restore)\s+\./, label: 'git checkout/restore . (discards all working tree changes)' }, { pattern: /rm\s+-[rf]/, label: 'rm -rf (recursive/force delete)' }, { pattern: /chmod\s+(-R\s+)?777/, label: 'chmod 777 (world-writable permissions)' }, { pattern: /npm\s+publish/, label: 'npm publish (publishes package to registry)' }, { pattern: /pip\s+upload|twine\s+upload/, label: 'pip/twine upload (publishes package to PyPI)' }, { pattern: /docker\s+system\s+prune/, label: 'docker system prune (removes all unused resources)' }, { pattern: /DROP\s+(DATABASE|TABLE)/i, label: 'SQL DROP (destroys database/table)' }, { pattern: /DELETE\s+FROM\s+(?!.*WHERE)/i, label: 'DELETE without WHERE (deletes all rows)' }, { pattern: /UPDATE\s+\S+\s+SET\s+(?!.*WHERE)/i, label: 'UPDATE without WHERE (updates all rows)' }, { pattern: /TRUNCATE\s+TABLE/i, label: 'SQL TRUNCATE (empties entire table)' }, ]; for (const { pattern, label } of confirmPatterns) { if (pattern.test(command)) { confirmLabel = label; break; } } } // === File write security check === } else if (toolName === 'Write' || toolName === 'Edit') { const filePath = input.tool_input?.file_path || ''; const homeDir = os.homedir(); const repoRoot = common.getRepoRoot(cwd); const resolved = path.resolve(cwd, filePath); // Tier 1: Block — system paths const sensitivePaths = [ '/etc/', '/usr/bin/', '/usr/sbin/', '/bin/', '/sbin/', '/System/', '/dev/', '/proc/', '/sys/' ]; for (const sp of sensitivePaths) { if (filePath.startsWith(sp)) { decision = 'deny'; reason = `Writing to system path denied: ${sp}`; break; } } // Block — outside repo and home if (decision === 'allow') { const insideRepo = isPathInside(repoRoot, resolved); const insideHome = isPathInside(homeDir, resolved); if (!insideRepo && !insideHome) { decision = 'deny'; reason = 'Path traversal attack detected: resolved path is outside the repository and home directory'; } else if (!insideRepo && insideHome) { const relativeHomePath = path.relative(homeDir, resolved) || path.basename(resolved); confirmLabel = `home directory path outside repo (~/${relativeHomePath})`; } } // Tier 2: Confirm — sensitive files if (decision === 'allow') { const fileName = path.basename(filePath); if (fileName.startsWith('.env')) { confirmLabel = `.env file (${fileName})`; } if (!confirmLabel) { const sensitiveFiles = ['credentials.json', 'key.pem', 'key.json', 'id_rsa']; for (const sf of sensitiveFiles) { if (fileName === sf) { confirmLabel = `sensitive file (${sf})`; break; } } } if (!confirmLabel) { const sensitivePathPatterns = ['.aws/credentials', '.npmrc']; for (const sp of sensitivePathPatterns) { if (filePath.includes(sp)) { confirmLabel = `sensitive path (${sp})`; break; } } } } } // === Build output === if (decision === 'deny') { const errorOutput = { hookSpecificOutput: { permissionDecision: 'deny' }, systemMessage: `🛑 Blocked: ${reason}\n\nTo perform this operation, run it manually in the terminal.` }; console.error(JSON.stringify(errorOutput)); process.exit(2); } else { const result = { continue: true }; if (confirmLabel) { result.systemMessage = `⚠️ CONFIRM REQUIRED: ${confirmLabel}\n\nYou MUST ask the user for explicit confirmation before executing this operation. Do NOT proceed without user approval.`; } console.log(JSON.stringify(result)); process.exit(0); }