#!/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 1: Pre-action validation */ function runPreActionCheck() { try { // Determine action type based on file extension let actionType = 'file-edit'; // Run pre-action-check.js execSync( `node ${path.join(__dirname, '../pre-action-check.js')} ${actionType} "${FILE_PATH}" "Hook validation"`, { encoding: 'utf8', stdio: 'pipe' } ); return { passed: true }; } catch (err) { // Pre-action check failed (non-zero exit code) return { passed: false, reason: 'Pre-action check failed (CSP violation or file restriction)', output: err.stdout || err.message }; } } /** * 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 }; } /** * 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 preCheck = runPreActionCheck(); if (!preCheck.passed) { error(preCheck.reason); if (preCheck.output) { console.log(preCheck.output); } logMetrics('blocked', preCheck.reason); process.exit(2); // Exit code 2 = BLOCK } success('Pre-action check 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'); // 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 });