#!/usr/bin/env node /** * Hook Validator: File Edit * * Runs BEFORE Edit tool execution to enforce governance requirements. * This is architectural enforcement - AI cannot bypass this check. * * Checks: * 1. Pre-action validation (CSP, file type restrictions) * 2. CrossReferenceValidator (instruction conflicts) * 3. BoundaryEnforcer (values decisions) * * Exit codes: * 0 = PASS (allow edit) * 1 = FAIL (block edit) * * Copyright 2025 Tractatus Project * Licensed under Apache License 2.0 */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // Hooks receive input via stdin as JSON let HOOK_INPUT = null; let FILE_PATH = null; const SESSION_STATE_PATH = path.join(__dirname, '../../.claude/session-state.json'); const INSTRUCTION_HISTORY_PATH = path.join(__dirname, '../../.claude/instruction-history.json'); /** * Color output */ const colors = { reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m' }; 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'); } /** * Check 1a: CSP Compliance on content AFTER edit * Simulates the edit and validates the result */ function checkCSPComplianceAfterEdit() { const oldString = HOOK_INPUT.tool_input.old_string; const newString = HOOK_INPUT.tool_input.new_string; if (!oldString || newString === undefined) { warning('No old_string/new_string in tool input - skipping CSP check'); return { passed: true }; } // Only check HTML/JS files const ext = path.extname(FILE_PATH).toLowerCase(); if (!['.html', '.js'].includes(ext)) { return { passed: true }; } // Exclude scripts/ directory (not served to browsers) if (FILE_PATH.includes('/scripts/')) { return { passed: true }; } // Read current file content if (!fs.existsSync(FILE_PATH)) { warning(`File does not exist yet: ${FILE_PATH} - skipping CSP check`); return { passed: true }; } let currentContent; try { currentContent = fs.readFileSync(FILE_PATH, 'utf8'); } catch (err) { warning(`Could not read file: ${err.message} - skipping CSP check`); return { passed: true }; } // Simulate the edit const contentAfterEdit = currentContent.replace(oldString, newString); // Check for CSP violations in the RESULT const violations = []; const patterns = [ { name: 'Inline event handlers', regex: /\son\w+\s*=\s*["'][^"']*["']/gi, severity: 'CRITICAL' }, { name: 'Inline styles', regex: /\sstyle\s*=\s*["'][^"']+["']/gi, severity: 'CRITICAL' }, { name: 'Inline scripts (without src)', regex: /]*\ssrc=)[^>]*>[\s\S]*?<\/script>/gi, severity: 'WARNING', filter: (match) => match.replace(/]*>|<\/script>/gi, '').trim().length > 0 }, { name: 'javascript: URLs', regex: /href\s*=\s*["']javascript:[^"']*["']/gi, severity: 'CRITICAL' } ]; patterns.forEach(pattern => { const matches = contentAfterEdit.match(pattern.regex); if (matches) { const filtered = pattern.filter ? matches.filter(pattern.filter) : matches; if (filtered.length > 0) { violations.push({ name: pattern.name, severity: pattern.severity, count: filtered.length, samples: filtered.slice(0, 3) }); } } }); if (violations.length === 0) { return { passed: true }; } // Report violations const output = []; output.push(`CSP violations detected in content AFTER edit for ${path.basename(FILE_PATH)}:`); violations.forEach(v => { output.push(` [${v.severity}] ${v.name} (${v.count} occurrences)`); v.samples.forEach((sample, idx) => { const truncated = sample.length > 80 ? sample.substring(0, 77) + '...' : sample; output.push(` ${idx + 1}. ${truncated}`); }); }); return { passed: false, reason: 'CSP violations in content after edit', output: output.join('\n') }; } /** * Check 1b: Other pre-action validations (skip CSP, handle separately) */ function runOtherPreActionChecks() { // Skip pre-action-check.js to avoid catch-22 // CSP is handled by checkCSPComplianceAfterEdit() return { passed: true }; } /** * Check 2: CrossReferenceValidator - Check for instruction conflicts */ function checkInstructionConflicts() { try { if (!fs.existsSync(INSTRUCTION_HISTORY_PATH)) { return { passed: true, conflicts: [] }; } const history = JSON.parse(fs.readFileSync(INSTRUCTION_HISTORY_PATH, 'utf8')); const activeInstructions = history.instructions?.filter(i => i.active) || []; // Check if any HIGH persistence instructions might conflict with this file edit const highPriorityInstructions = activeInstructions.filter(i => i.persistence === 'HIGH'); if (highPriorityInstructions.length === 0) { return { passed: true, conflicts: [] }; } // Check file path against instruction contexts const conflicts = highPriorityInstructions.filter(instruction => { // If instruction mentions specific files or paths if (instruction.context && typeof instruction.context === 'string') { return instruction.context.includes(FILE_PATH) || FILE_PATH.includes(instruction.context); } return false; }); if (conflicts.length > 0) { return { passed: false, reason: `Conflicts with ${conflicts.length} HIGH persistence instruction(s)`, conflicts: conflicts.map(c => ({ id: c.id, instruction: c.instruction, quadrant: c.quadrant })) }; } return { passed: true, conflicts: [] }; } catch (err) { warning(`Could not check instruction conflicts: ${err.message}`); return { passed: true, conflicts: [] }; // Fail open on error } } /** * Check 3: BoundaryEnforcer - Values decisions */ function checkBoundaryViolation() { // Check if file path suggests values content const valuesIndicators = [ '/docs/values/', '/docs/ethics/', 'privacy-policy', 'code-of-conduct', 'values.html', '/pluralistic-values' ]; const isValuesContent = valuesIndicators.some(indicator => FILE_PATH.toLowerCase().includes(indicator.toLowerCase()) ); if (isValuesContent) { return { passed: false, reason: 'File appears to contain values content - requires human approval', requiresHumanApproval: true }; } return { passed: true }; } /** * Check 4: Credential Protection - Prevent unauthorized credential changes */ function checkCredentialProtection() { try { const validatorPath = path.join(__dirname, 'validate-credentials.js'); if (!fs.existsSync(validatorPath)) { warning('Credential validator not found - skipping check'); return { passed: true }; } const { validate } = require(validatorPath); const result = validate(FILE_PATH, 'edit'); if (!result.valid) { return { passed: false, reason: 'Protected credential change detected', output: result.message }; } return { passed: true }; } catch (err) { warning(`Credential check error: ${err.message}`); return { passed: true }; // Fail open on error } } /** * Update session state with hook execution */ function updateSessionState() { try { if (fs.existsSync(SESSION_STATE_PATH)) { const sessionState = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8')); sessionState.last_framework_activity = sessionState.last_framework_activity || {}; sessionState.last_framework_activity.FileEditHook = { timestamp: new Date().toISOString(), file: FILE_PATH, result: 'passed' }; sessionState.last_updated = new Date().toISOString(); fs.writeFileSync(SESSION_STATE_PATH, JSON.stringify(sessionState, null, 2)); } } catch (err) { // Non-critical - don't fail on logging errors } } /** * Log metrics for hook execution */ function logMetrics(result, reason = null) { try { const METRICS_PATH = path.join(__dirname, '../../.claude/metrics/hooks-metrics.json'); const METRICS_DIR = path.dirname(METRICS_PATH); // Ensure directory exists if (!fs.existsSync(METRICS_DIR)) { fs.mkdirSync(METRICS_DIR, { recursive: true }); } // Load existing metrics let metrics = { hook_executions: [], blocks: [], session_stats: {} }; if (fs.existsSync(METRICS_PATH)) { metrics = JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8')); } // Log execution metrics.hook_executions.push({ hook: 'validate-file-edit', timestamp: new Date().toISOString(), file: FILE_PATH, result: result, reason: reason }); // Log block if failed if (result === 'blocked') { metrics.blocks.push({ hook: 'validate-file-edit', timestamp: new Date().toISOString(), file: FILE_PATH, reason: reason }); } // Update session stats metrics.session_stats.total_edit_hooks = (metrics.session_stats.total_edit_hooks || 0) + 1; metrics.session_stats.total_edit_blocks = (metrics.session_stats.total_edit_blocks || 0) + (result === 'blocked' ? 1 : 0); metrics.session_stats.last_updated = new Date().toISOString(); // Keep only last 1000 executions if (metrics.hook_executions.length > 1000) { metrics.hook_executions = metrics.hook_executions.slice(-1000); } // Keep only last 500 blocks if (metrics.blocks.length > 500) { metrics.blocks = metrics.blocks.slice(-500); } // Write metrics fs.writeFileSync(METRICS_PATH, JSON.stringify(metrics, null, 2)); } catch (err) { // Non-critical - don't fail on metrics logging } } /** * Read hook input from stdin */ function readHookInput() { return new Promise((resolve) => { let data = ''; process.stdin.on('data', chunk => { data += chunk; }); process.stdin.on('end', () => { try { resolve(JSON.parse(data)); } catch (err) { resolve(null); } }); }); } /** * Main validation */ async function main() { // Read input from stdin HOOK_INPUT = await readHookInput(); if (!HOOK_INPUT || !HOOK_INPUT.tool_input || !HOOK_INPUT.tool_input.file_path) { error('No file path provided in hook input'); logMetrics('error', 'No file path in input'); process.exit(2); // Exit code 2 = BLOCK } FILE_PATH = HOOK_INPUT.tool_input.file_path; log(`\nšŸ” Hook: Validating file edit: ${FILE_PATH}`, 'cyan'); // Check 1: Pre-action validation const cspCheck = checkCSPComplianceAfterEdit(); if (!cspCheck.passed) { error(cspCheck.reason); if (cspCheck.output) { console.log(cspCheck.output); } logMetrics('blocked', cspCheck.reason); process.exit(2); // Exit code 2 = BLOCK } success('CSP compliance validated on content after edit'); // Check 1b: Other pre-action checks const otherChecks = runOtherPreActionChecks(); if (!otherChecks.passed) { error(otherChecks.reason); if (otherChecks.output) { console.log(otherChecks.output); } logMetrics('blocked', otherChecks.reason); process.exit(2); // Exit code 2 = BLOCK } success('Other pre-action checks passed'); // Check 2: CrossReferenceValidator const conflicts = checkInstructionConflicts(); if (!conflicts.passed) { error(conflicts.reason); conflicts.conflicts.forEach(c => { log(` • ${c.id}: ${c.instruction} [${c.quadrant}]`, 'yellow'); }); logMetrics('blocked', conflicts.reason); process.exit(2); // Exit code 2 = BLOCK } success('No instruction conflicts detected'); // Check 3: BoundaryEnforcer const boundary = checkBoundaryViolation(); if (!boundary.passed) { error(boundary.reason); logMetrics('blocked', boundary.reason); process.exit(2); // Exit code 2 = BLOCK } success('No boundary violations detected'); // Check 4: Credential Protection const credentials = checkCredentialProtection(); if (!credentials.passed) { error(credentials.reason); if (credentials.output) { console.log(credentials.output); } logMetrics('blocked', credentials.reason); process.exit(2); // Exit code 2 = BLOCK } success('No protected credential changes detected'); // Update session state updateSessionState(); // Log successful execution logMetrics('passed'); success('File edit validation complete\n'); process.exit(0); } main().catch(err => { error(`Hook validation error: ${err.message}`); logMetrics('error', err.message); process.exit(2); // Exit code 2 = BLOCK });