#!/usr/bin/env node /** * Hook Validator: Bash Command * * Runs BEFORE Bash tool execution to enforce governance requirements. * This is architectural enforcement - AI cannot bypass this check. * * Checks: * 1. Deployment patterns (inst_025: separate rsync per directory level) * 2. Permission requirements (inst_020, inst_022: chmod flags) * 3. CrossReferenceValidator (instruction conflicts with command) * 4. Pre-action-check enforcement * * Exit codes: * 0 = PASS (allow command) * 1 = FAIL (block command) * * Copyright 2025 Tractatus Project * Licensed under Apache License 2.0 */ const fs = require('fs'); const path = require('path'); // Import CrossReferenceValidator const CrossReferenceValidator = require('../framework-components/CrossReferenceValidator.js'); // Hooks receive input via stdin as JSON let HOOK_INPUT = null; let BASH_COMMAND = null; const SESSION_STATE_PATH = path.join(__dirname, '../../.claude/session-state.json'); const INSTRUCTION_HISTORY_PATH = path.join(__dirname, '../../.claude/instruction-history.json'); const METRICS_PATH = path.join(__dirname, '../../.claude/metrics/hooks-metrics.json'); /** * Color output */ const colors = { reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m', bold: '\x1b[1m' }; function log(message, color = 'reset') { console.log(`${colors[color]}${message}${colors.reset}`); } function error(message) { log(` ✗ ${message}`, 'red'); } function warning(message) { log(` ⚠ ${message}`, 'yellow'); } function success(message) { log(` ✓ ${message}`, 'green'); } function header(message) { log(`\n${colors.cyan}${colors.bold}[BASH VALIDATOR]${colors.reset} ${message}`, 'cyan'); } /** * Parse Bash command for rsync pattern analysis */ function parseRsyncCommand(command) { // Match rsync commands const rsyncMatch = command.match(/rsync\s+([^|&;]*)/); if (!rsyncMatch) return null; const fullCommand = rsyncMatch[1]; // Extract source files (before remote:) const parts = fullCommand.split(/\s+(?=\w+@|ubuntu@)/); if (parts.length < 2) return null; const sourceSection = parts[0]; const targetSection = parts[1]; // Extract all file paths from source section (exclude flags) const sourcePaths = sourceSection .split(/\s+/) .filter(part => !part.startsWith('-') && !part.startsWith('/dev/') && part.includes('/')) .filter(part => !part.includes('--')); // Extract target path const targetMatch = targetSection.match(/[^:]+:(.+)/); const targetPath = targetMatch ? targetMatch[1] : null; return { sourcePaths, targetPath, fullCommand }; } /** * Check inst_025: Deployment directory structure */ function checkDeploymentDirectoryStructure() { if (!BASH_COMMAND.includes('rsync')) { return { passed: true }; } // Exclude dry-run commands (testing is fine) if (BASH_COMMAND.includes('-n') || BASH_COMMAND.includes('--dry-run')) { return { passed: true }; } const parsed = parseRsyncCommand(BASH_COMMAND); if (!parsed) { return { passed: true }; } const { sourcePaths, targetPath } = parsed; // If only one source file, no directory flattening risk if (sourcePaths.length <= 1) { return { passed: true }; } // Check if source files have different directory structures const directories = sourcePaths.map(p => { const dir = path.dirname(p); return dir; }); // Check for different subdirectory levels const uniqueDirs = [...new Set(directories)]; if (uniqueDirs.length > 1) { // Multiple different directories - potential flattening risk const violations = []; // Analyze directory depth differences const depths = uniqueDirs.map(dir => dir.split('/').length); const minDepth = Math.min(...depths); const maxDepth = Math.max(...depths); if (maxDepth > minDepth) { violations.push({ issue: 'Directory structure mismatch', details: `Source files from different directory levels (depth ${minDepth}-${maxDepth})`, sources: sourcePaths, target: targetPath, instruction: 'inst_025' }); } if (violations.length > 0) { error('inst_025 VIOLATION: Deployment directory structure error'); log(''); violations.forEach(v => { error(`Issue: ${v.issue}`); error(`Details: ${v.details}`); log(''); log(' Source files:', 'yellow'); v.sources.forEach(s => log(` - ${s}`, 'yellow')); log(` Target: ${v.target}`, 'yellow'); log(''); error('This will flatten directory structure!'); log(''); log(' inst_025 requires:', 'cyan'); log(' "When source files have different subdirectories,', 'cyan'); log(' use SEPARATE rsync commands for each directory level"', 'cyan'); log(''); log(' Correct approach:', 'green'); v.sources.forEach((s, i) => { const dir = path.dirname(s); const filename = path.basename(s); log(` ${i + 1}. rsync ... ${s} remote:${dir}/`, 'green'); }); }); return { passed: false, violations, instruction: 'inst_025', message: 'Multiple source files from different directories will flatten structure' }; } } return { passed: true }; } /** * Check inst_022: rsync chmod flags for permissions */ function checkRsyncPermissions() { if (!BASH_COMMAND.includes('rsync')) { return { passed: true }; } // Check if deploying to production if (!BASH_COMMAND.includes('vps-93a693da') && !BASH_COMMAND.includes('/var/www/')) { return { passed: true }; } // Exclude dry-run if (BASH_COMMAND.includes('-n') || BASH_COMMAND.includes('--dry-run')) { return { passed: true }; } // Check for --chmod flag if (!BASH_COMMAND.includes('--chmod')) { warning('inst_022: rsync to production without --chmod flag'); warning('Recommended: --chmod=D755,F644'); warning('This is a warning, not blocking (but should be fixed)'); return { passed: true }; // Warning only, not blocking } return { passed: true }; } /** * Check inst_064: File write redirects (prevent bash bypass of Write tool) */ function checkFileWriteRedirects() { // Patterns that write to files using bash redirects const redirectPatterns = [ { pattern: /cat\s+>\s*[^\s|&;]+/, description: 'cat > file' }, { pattern: /cat\s+>>\s*[^\s|&;]+/, description: 'cat >> file' }, { pattern: /cat\s*<<\s*['"]?EOF['"]?/, description: 'cat << EOF > file (heredoc)' }, { pattern: /echo\s+.*>\s*[^\s|&;]+/, description: 'echo > file' }, { pattern: /echo\s+.*>>\s*[^\s|&;]+/, description: 'echo >> file' }, { pattern: /printf\s+.*>\s*[^\s|&;]+/, description: 'printf > file' }, { pattern: /tee\s+[^\s|&;]+/, description: 'tee file' } ]; // Exception: Allow redirects to /dev/null or stderr/stdout if (BASH_COMMAND.includes('> /dev/null') || BASH_COMMAND.includes('2>&1') || BASH_COMMAND.includes('1>&2')) { return { passed: true }; } // Check for redirect patterns for (const { pattern, description } of redirectPatterns) { if (pattern.test(BASH_COMMAND)) { error('inst_064 VIOLATION: Bash file write detected'); log(''); error(`Pattern: ${description}`); log(''); log(' inst_064 requires:', 'cyan'); log(' "File operations MUST use Write tool (creation),', 'cyan'); log(' Edit tool (modification), Read tool (reading).', 'cyan'); log(' Bash tool is for terminal operations ONLY."', 'cyan'); log(''); log(' Correct approach:', 'green'); log(' Instead of: cat > file.txt << EOF', 'red'); log(' Use Write tool:', 'green'); log(' Write({', 'green'); log(' file_path: "/path/to/file.txt",', 'green'); log(' content: "your content here"', 'green'); log(' })', 'green'); log(''); error('This prevents bypassing Write tool hook validation'); log(''); return { passed: false, violations: [{ issue: 'Bash file write redirect detected', pattern: description, instruction: 'inst_064' }], instruction: 'inst_064', message: `Bash file write operations must use Write tool instead: ${description}` }; } } return { passed: true }; } /** * Check pre-action-check requirement (inst_038) */ function checkPreActionCheckRecency() { // Only enforce for deployment commands if (!BASH_COMMAND.includes('rsync') && !BASH_COMMAND.includes('deploy')) { return { passed: true }; } // Check if pre-action-check was run recently let sessionState; try { if (!fs.existsSync(SESSION_STATE_PATH)) { return { passed: true }; // No session state, can't verify } sessionState = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8')); } catch (error) { warning(`Could not load session state: ${error.message}`); return { passed: true }; } const lastPreActionCheck = sessionState.last_pre_action_check; const currentAction = sessionState.action_count || 0; if (!lastPreActionCheck) { warning('No pre-action-check recorded in this session'); warning('Consider running: node scripts/pre-action-check.js deployment ""'); return { passed: true }; // Warning only for now } // Check if pre-action-check is recent (within last 5 actions) if (currentAction - lastPreActionCheck > 5) { warning(`Pre-action-check last run ${currentAction - lastPreActionCheck} actions ago`); warning('Consider running: node scripts/pre-action-check.js deployment ""'); } return { passed: true }; } /** * Update session state with validation */ function updateSessionState(validationResult) { try { let sessionState = {}; if (fs.existsSync(SESSION_STATE_PATH)) { sessionState = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8')); } if (!sessionState.framework_components) { sessionState.framework_components = {}; } if (!sessionState.framework_components.BashCommandValidator) { sessionState.framework_components.BashCommandValidator = { message: 0, tokens: 0, timestamp: null, last_validation: null, validations_performed: 0, blocks_issued: 0 }; } sessionState.framework_components.BashCommandValidator.last_validation = new Date().toISOString(); sessionState.framework_components.BashCommandValidator.validations_performed += 1; if (!validationResult.passed) { sessionState.framework_components.BashCommandValidator.blocks_issued += 1; } // Increment action count sessionState.action_count = (sessionState.action_count || 0) + 1; fs.writeFileSync(SESSION_STATE_PATH, JSON.stringify(sessionState, null, 2)); } catch (error) { warning(`Could not update session state: ${error.message}`); } } /** * Update hook metrics */ function updateMetrics(result) { try { let metrics = { executions: [], session_stats: {} }; if (fs.existsSync(METRICS_PATH)) { metrics = JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8')); } if (!metrics.executions) { metrics.executions = []; } const execution = { hook: 'validate-bash-command', timestamp: new Date().toISOString(), command: BASH_COMMAND.substring(0, 100), // First 100 chars result: result.passed ? 'passed' : 'blocked' }; if (!result.passed) { execution.reason = result.message; execution.instruction = result.instruction; } metrics.executions.push(execution); // Update session stats if (!metrics.session_stats) { metrics.session_stats = {}; } metrics.session_stats.total_bash_hooks = (metrics.session_stats.total_bash_hooks || 0) + 1; if (!result.passed) { metrics.session_stats.total_bash_blocks = (metrics.session_stats.total_bash_blocks || 0) + 1; } metrics.session_stats.last_updated = new Date().toISOString(); // Keep only last 1000 executions if (metrics.executions.length > 1000) { metrics.executions = metrics.executions.slice(-1000); } fs.writeFileSync(METRICS_PATH, JSON.stringify(metrics, null, 2)); } catch (error) { warning(`Could not update metrics: ${error.message}`); } } /** * Check CrossReferenceValidator (automatic invocation) */ function checkCrossReferenceValidator() { try { const validator = new CrossReferenceValidator({ silent: false }); const result = validator.validate('bash', { command: BASH_COMMAND }); if (!result.passed) { return { passed: false, message: result.conflicts.map(c => c.issue).join('; '), instruction: result.conflicts[0]?.instruction || 'unknown' }; } return { passed: true }; } catch (error) { warning(`CrossReferenceValidator error: ${error.message}`); return { passed: true }; // Don't block on errors } } /** * Main validation function */ function main() { header('Bash Command Pre-Execution Check'); // Read stdin for hook input try { const stdin = fs.readFileSync(0, 'utf-8'); HOOK_INPUT = JSON.parse(stdin); BASH_COMMAND = HOOK_INPUT.tool_input?.command; if (!BASH_COMMAND) { error('No Bash command in hook input'); process.exit(1); } log(`Command: ${BASH_COMMAND.substring(0, 80)}${BASH_COMMAND.length > 80 ? '...' : ''}`, 'cyan'); log(''); } catch (error) { error(`Failed to parse hook input: ${error.message}`); process.exit(2); } // Run checks const checks = [ { name: 'File write redirects (inst_064)', fn: checkFileWriteRedirects }, { name: 'CrossReferenceValidator (instruction conflicts)', fn: checkCrossReferenceValidator }, { name: 'Deployment directory structure (inst_025)', fn: checkDeploymentDirectoryStructure }, { name: 'Rsync permissions (inst_022)', fn: checkRsyncPermissions }, { name: 'Pre-action-check recency (inst_038)', fn: checkPreActionCheckRecency } ]; let allPassed = true; let failedCheck = null; for (const check of checks) { log(`Checking: ${check.name}`, 'cyan'); const result = check.fn(); if (result.passed) { success('PASS'); } else { error('FAIL'); allPassed = false; failedCheck = result; break; } } // Update session state and metrics updateSessionState({ passed: allPassed }); updateMetrics({ passed: allPassed, message: failedCheck?.message, instruction: failedCheck?.instruction }); log(''); if (allPassed) { success('✓ All checks passed - command allowed'); process.exit(0); } else { log(''); error('✗ COMMAND BLOCKED - Governance violation detected'); error(`Violates: ${failedCheck.instruction}`); log(''); error('Fix the command and try again.'); log(''); process.exit(1); } } // Run validator main();