#!/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'); // 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.error(`${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: Pre-action-check enforcement (inst_038) */ function checkPreActionCheckRecency() { // Only enforce for major file changes (not minor edits) // 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 edit ' + 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 edit ' + FILE_PATH); } return { passed: true }; } catch (error) { warning(`Could not check pre-action-check recency: ${error.message}`); return { passed: true }; } } /** * Check 2: CrossReferenceValidator - Check for instruction conflicts */ function checkInstructionConflicts() { try { const oldString = HOOK_INPUT.tool_input?.old_string || ''; const newString = HOOK_INPUT.tool_input?.new_string || ''; const validator = new CrossReferenceValidator({ silent: false }); const result = validator.validate('file-edit', { filePath: FILE_PATH, oldString, newString }); 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: [] }; // 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', HOOK_INPUT); 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 (both file-based and MongoDB) */ async function logMetrics(result, reason = null) { try { // 1. File-based metrics (legacy) 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)); // 2. MongoDB audit log (for dashboard visibility) await logToAuditDatabase(result, reason); } catch (err) { // Non-critical - don't fail on metrics logging } } /** * Log hook execution to MongoDB AuditLog for dashboard visibility */ async function logToAuditDatabase(result, reason) { try { const mongoose = require('mongoose'); // Skip if not connected (hook runs before DB init) if (mongoose.connection.readyState !== 1) { return; } const AuditLog = mongoose.model('AuditLog'); // Get session ID from state file let sessionId = 'unknown'; try { const sessionState = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8')); sessionId = sessionState.session_id || 'unknown'; } catch (e) { // Ignore } // Determine rule violations based on reason const violations = []; if (result === 'blocked' && reason) { // Parse reason to extract rule ID if present const ruleMatch = reason.match(/inst_\d+/); violations.push({ ruleId: ruleMatch ? ruleMatch[0] : 'hook-validator', ruleText: reason, severity: 'HIGH', details: reason }); } // Classify activity for business intelligence const activityClassifier = require('../../src/utils/activity-classifier.util'); const classification = activityClassifier.classifyActivity('file_edit_hook', { filePath: FILE_PATH, reason: reason, service: 'FileEditHook' }); const businessImpact = activityClassifier.calculateBusinessImpact( classification, result === 'blocked' ); // Create audit log entry await AuditLog.create({ sessionId: sessionId, action: 'file_edit_hook', allowed: result !== 'blocked', rulesChecked: ['inst_072', 'inst_084', 'inst_038'], // Common hook rules violations: violations, metadata: { filePath: FILE_PATH, hook: 'validate-file-edit', reason: reason }, domain: 'SYSTEM', service: 'FileEditHook', environment: process.env.NODE_ENV || 'development', is_local: true, timestamp: new Date(), // Business intelligence context activityType: classification.activityType, riskLevel: classification.riskLevel, stakeholderImpact: classification.stakeholderImpact, dataSensitivity: classification.dataSensitivity, reversibility: classification.reversibility, businessImpact: businessImpact }); } catch (err) { // Non-critical - don't fail on audit logging // console.error('Audit log error:', err.message); } } /** * 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'); await 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); } await 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 preActionCheck = checkPreActionCheckRecency(); if (!preActionCheck.passed) { error(preActionCheck.reason); if (preActionCheck.output) { console.log(preActionCheck.output); } await logMetrics('blocked', preActionCheck.reason); process.exit(2); // Exit code 2 = BLOCK } success('Pre-action-check recency (inst_038) 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'); }); await 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); await 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); } await logMetrics('blocked', credentials.reason); process.exit(2); // Exit code 2 = BLOCK } success('No protected credential changes detected'); // Check 5: GitHub URL Protection (inst_084) const githubUrlCheck = checkGitHubURLProtection(); if (!githubUrlCheck.passed) { error(githubUrlCheck.reason); if (githubUrlCheck.output) { console.error(githubUrlCheck.output); } await logMetrics('blocked', githubUrlCheck.reason); process.exit(2); // Exit code 2 = BLOCK } success('No unauthorized GitHub URL modifications detected (inst_084)'); // Update session state updateSessionState(); // Log successful execution await logMetrics('passed'); success('File edit validation complete\n'); process.exit(0); } main().catch(async (err) => { error(`Hook validation error: ${err.message}`); await logMetrics('error', err.message); process.exit(2); // Exit code 2 = BLOCK }); /** * Check 5: GitHub URL Protection (inst_084) - HARD BLOCK unauthorized changes */ function checkGitHubURLProtection() { const oldString = HOOK_INPUT.tool_input?.old_string || ''; const newString = HOOK_INPUT.tool_input?.new_string || ''; // Only check if both strings contain github.com if (!oldString.includes('github.com') && !newString.includes('github.com')) { return { passed: true }; } // Extract GitHub repository URLs (github.com/org/repo format) const githubUrlPattern = /github\.com\/[\w-]+\/[\w-]+/g; const oldUrls = oldString.match(githubUrlPattern) || []; const newUrls = newString.match(githubUrlPattern) || []; // Check 1: New GitHub URLs added (requires approval) if (newUrls.length > oldUrls.length) { return { passed: false, reason: 'inst_084: New GitHub URL detected - requires explicit approval', output: `Attempting to add GitHub URL:\n New: ${newUrls.join(', ')}\n Old: ${oldUrls.join(', ')}\n\nBLOCKED: Adding GitHub URLs requires explicit human approval to prevent exposure of private repository structure.` }; } // Check 2: GitHub repository name changed (HIGH RISK) if (oldUrls.length > 0 && newUrls.length > 0) { const oldRepos = new Set(oldUrls); const newRepos = new Set(newUrls); // Find any repository name changes for (const oldRepo of oldRepos) { if (!newRepos.has(oldRepo)) { return { passed: false, reason: 'inst_084: GitHub repository name change detected - HARD BLOCK', output: `CRITICAL: Repository URL modification detected:\n Old: ${oldRepo}\n New: ${Array.from(newRepos).join(', ')}\n\nBLOCKED: This could expose private repository structure (e.g., tractatus-framework → tractatus exposes /src/services/).\n\nRequired: Explicit human approval for ANY GitHub repository URL changes.` }; } } } // Check 3: GitHub URL path changed (e.g., /tree/main/src/services → /tree/main/deployment-quickstart) const oldPaths = oldString.match(/github\.com[^\s"'<>]*/g) || []; const newPaths = newString.match(/github\.com[^\s"'<>]*/g) || []; if (oldPaths.length > 0 && newPaths.length > 0) { for (let i = 0; i < Math.min(oldPaths.length, newPaths.length); i++) { if (oldPaths[i] !== newPaths[i]) { return { passed: false, reason: 'inst_084: GitHub URL path modification detected - requires approval', output: `GitHub URL path change detected:\n Old: ${oldPaths[i]}\n New: ${newPaths[i]}\n\nBLOCKED: URL path modifications require explicit approval to prevent linking to non-existent or private paths.` }; } } } return { passed: true }; }