Implements comprehensive monitoring and fixes hook execution issues. Hook Validator Enhancements: - Fixed stdin JSON input reading (was using argv, now reads from stdin) - Changed exit codes from 1 to 2 for proper blocking (Claude Code spec) - Added metrics logging to all validators (Edit and Write hooks) - Metrics track: executions, blocks, success rates, timestamps Admin Dashboard: - Created /admin/hooks-dashboard.html - Real-time metrics visualization - Shows: total executions, blocks, block rates, hook breakdown - Displays recent blocked operations and activity feed - Auto-refreshes every 30 seconds API Integration: - Created /api/admin/hooks/metrics endpoint - Serves metrics.json to admin dashboard - Protected by admin authentication middleware Metrics Storage: - Created .claude/metrics/hooks-metrics.json - Tracks last 1000 executions, 500 blocks - Session stats: total hooks, blocks, last updated - Proven working: 11 hook executions logged during implementation Bug Fix: - Resolved "non-blocking status code 1" issue - Hooks now properly receive tool parameters via stdin JSON - Exit code 2 properly blocks operations per Claude Code spec Impact: - Framework enforcement is now observable and measurable - Admin can monitor hook effectiveness in real-time - Validates architectural enforcement approach 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
325 lines
8.7 KiB
JavaScript
Executable file
325 lines
8.7 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');
|
|
|
|
// 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
|
|
});
|