#!/usr/bin/env node /** * Hook Validator: File Write * * Runs BEFORE Write 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. Overwrite without read check (file exists but not read) * 3. CrossReferenceValidator (instruction conflicts) * 4. BoundaryEnforcer (values decisions) * * Exit codes: * 0 = PASS (allow write) * 1 = FAIL (block write) * * Copyright 2025 Tractatus Project * Licensed under Apache License 2.0 */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // Import CrossReferenceValidator const CrossReferenceValidator = require('../framework-components/CrossReferenceValidator.js'); // 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 NEW content * Validates the content being WRITTEN, not the existing file */ function checkCSPComplianceOnNewContent() { const newContent = HOOK_INPUT.tool_input.content; if (!newContent) { warning('No content provided 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 }; } const violations = []; // CSP Violation Patterns 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 = newContent.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 NEW content 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 new content', output: output.join('\n') }; } /** * Check 1b: Pre-action-check recency (inst_038) */ function checkPreActionCheckRecency() { // Only enforce for major file changes // Check if pre-action-check was run recently try { if (!fs.existsSync(SESSION_STATE_PATH)) { // No session state, allow but warn warning('No session state - pre-action-check recommended (inst_038)'); return { passed: true }; } const sessionState = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8')); const lastPreActionCheck = sessionState.last_pre_action_check; const currentAction = sessionState.action_count || 0; // If never run, just warn (don't block on first action) if (!lastPreActionCheck) { warning('inst_038: Pre-action-check not run in this session'); warning('Recommended: node scripts/pre-action-check.js write ' + FILE_PATH); return { passed: true }; // Warning only } // If last run was more than 10 actions ago, warn if (currentAction - lastPreActionCheck > 10) { warning(`inst_038: Pre-action-check last run ${currentAction - lastPreActionCheck} actions ago`); warning('Consider running: node scripts/pre-action-check.js write ' + FILE_PATH); } return { passed: true }; } catch (error) { warning(`Could not check pre-action-check recency: ${error.message}`); return { passed: true }; } } /** * Check 2: Overwrite without read * CLAUDE.md requires reading files before writing (to avoid overwrites) */ function checkOverwriteWithoutRead() { if (!fs.existsSync(FILE_PATH)) { // New file - no risk of overwrite return { passed: true }; } // File exists - this is an overwrite // In a real implementation, we'd check if the file was recently read // For now, we'll issue a warning but not block warning(`File exists - overwriting: ${FILE_PATH}`); warning(`Best practice: Read file before writing to avoid data loss`); return { passed: true }; // Warning only, not blocking } /** * Check 3: CrossReferenceValidator */ function checkInstructionConflicts() { try { const content = HOOK_INPUT.tool_input?.content || ''; const validator = new CrossReferenceValidator({ silent: false }); const result = validator.validate('file-write', { filePath: FILE_PATH, content }); if (!result.passed) { return { passed: false, reason: result.conflicts.map(c => c.issue).join('; '), conflicts: result.conflicts }; } return { passed: true, conflicts: [] }; } catch (err) { warning(`CrossReferenceValidator error: ${err.message}`); return { passed: true, conflicts: [] }; } } /** * Check 4: BoundaryEnforcer - Values decisions */ function checkBoundaryViolation() { 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 }; } /** * Update session state */ 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.FileWriteHook = { 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 } } /** * Main validation */ /** * 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); } }); }); } async function main() { // Read input from stdin HOOK_INPUT = await readHookInput(); if (!HOOK_INPUT || !HOOK_INPUT.tool_input || !HOOK_INPUT.tool_input.file_path) { logMetrics('error', 'No file path in input'); error('No file path provided in hook input'); process.exit(2); // Exit code 2 = BLOCK } FILE_PATH = HOOK_INPUT.tool_input.file_path; log(`\nšŸ” Hook: Validating file write: ${FILE_PATH}`, 'cyan'); // Check 1a: CSP Compliance on NEW content const cspCheck = checkCSPComplianceOnNewContent(); 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 new content'); // Check 1b: Pre-action-check recency (inst_038) const preActionCheck = checkPreActionCheckRecency(); if (!preActionCheck.passed) { error(preActionCheck.reason); if (preActionCheck.output) { console.log(preActionCheck.output); } logMetrics('blocked', preActionCheck.reason); process.exit(2); // Exit code 2 = BLOCK } success('Pre-action-check recency (inst_038) passed'); // Check 2: Overwrite without read const overwriteCheck = checkOverwriteWithoutRead(); if (!overwriteCheck.passed) { error(overwriteCheck.reason); logMetrics('blocked', overwriteCheck.reason); process.exit(2); // Exit code 2 = BLOCK } // Check 3: 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 4: 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'); // Update session state updateSessionState(); // Log successful execution logMetrics('passed'); success('File write 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 }); /** * 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); if (!fs.existsSync(METRICS_DIR)) { fs.mkdirSync(METRICS_DIR, { recursive: true }); } let metrics = { hook_executions: [], blocks: [], session_stats: {} }; if (fs.existsSync(METRICS_PATH)) { metrics = JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8')); } metrics.hook_executions.push({ hook: 'validate-file-write', timestamp: new Date().toISOString(), file: FILE_PATH, result: result, reason: reason }); if (result === 'blocked') { metrics.blocks.push({ hook: 'validate-file-write', timestamp: new Date().toISOString(), file: FILE_PATH, reason: reason }); } metrics.session_stats.total_write_hooks = (metrics.session_stats.total_write_hooks || 0) + 1; metrics.session_stats.total_write_blocks = (metrics.session_stats.total_write_blocks || 0) + (result === 'blocked' ? 1 : 0); metrics.session_stats.last_updated = new Date().toISOString(); if (metrics.hook_executions.length > 1000) { metrics.hook_executions = metrics.hook_executions.slice(-1000); } if (metrics.blocks.length > 500) { metrics.blocks = metrics.blocks.slice(-500); } fs.writeFileSync(METRICS_PATH, JSON.stringify(metrics, null, 2)); } catch (err) { // Non-critical } }