tractatus/scripts/hook-validators/validate-file-edit.js
TheFlow 3b54dde853 feat(implementer): major page redesign with hook architecture and responsive diagrams
## Implementer Page Enhancements

### Hero Section Redesign
- Changed title to "External Governance Services for AI Systems"
- Added three value proposition cards (Architectural Separation, Instruction Persistence, Audit Trail)
- Governance-compliant messaging (addresses vs prevents, designed to vs guarantees)
- Mobile-responsive card layout

### New "How It Works" Section
- Pattern Override Challenge explanation
- External Architecture Approach
- Request Flow with Governance diagram
- SVG download links

### New "Hook Architecture" Section (Credibility Layer)
- Architectural enforcement explanation
- Four real enforcement examples:
  * inst_084 GitHub URL Protection
  * inst_008 CSP Compliance
  * inst_027 Governance file protection
  * BoundaryEnforcer values decisions
- New hook-architecture.svg diagram showing PreToolUse flow
- Process separation and exit code enforcement details

### Deployment Section Improvements
- Removed broken "View Online" button
- PDF-only deployment guide download
- Simplified, cleaner presentation

### Responsive Diagrams
- Created system-architecture-mobile.svg (400x600px simplified)
- Created system-architecture-desktop.svg (full detail)
- Picture element with media queries for responsive switching
- Fixed request-flow-sequence.svg (restored from archive)

## Security & Governance

### inst_084 GitHub URL Modification Protocol
- HARD BLOCK on GitHub URL changes without explicit approval
- Prevents accidental private repository exposure
- Implemented in both validate-file-edit.js and validate-file-write.js
- Regex pattern matching for repository name changes
- Detailed error messages with context

### Hook Validator Improvements
- Fixed stderr output issue (console.log → console.error)
- Added checkGitHubURLProtection() function
- Enhanced error messaging for blocked actions

## Documentation

### New Deployment Guide
- Created comprehensive 14KB markdown guide (docs/markdown/deployment-guide.md)
- Generated 284KB PDF (public/docs/pdfs/deployment-guide.pdf)
- Covers: local dev, production, Docker, K8s, AWS, GCP, monitoring, security
- Removed MongoDB credential examples to comply with inst_069/070

### Diagram Archive
- Moved old diagrams to public/docs/diagrams/archive/
- Preserved deployment-architecture-old.svg
- Preserved request-flow-sequence-old.svg
- Preserved system-architecture-old.svg

## Cache & Version
- Bumped version to 0.1.2
- Updated changelog with all implementer changes
- forceUpdate: true for new diagrams and PDFs
- minVersion: 0.1.4

## Context
This addresses user feedback on implementer.html from 2025-10-26:
- Broken diagrams (404 errors, cut off at bottom)
- Need for credibility layer (hook architecture)
- GitHub URL security incident prevention
- Mobile responsiveness issues
- Deployment guide accessibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 23:14:22 +13:00

555 lines
16 KiB
JavaScript
Executable file

#!/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: /<script(?![^>]*\ssrc=)[^>]*>[\s\S]*?<\/script>/gi,
severity: 'WARNING',
filter: (match) => match.replace(/<script[^>]*>|<\/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');
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 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: 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');
// Check 5: GitHub URL Protection (inst_084)
const githubUrlCheck = checkGitHubURLProtection();
if (!githubUrlCheck.passed) {
error(githubUrlCheck.reason);
if (githubUrlCheck.output) {
console.error(githubUrlCheck.output);
}
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
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
});
/**
* 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 };
}