- Create Economist SubmissionTracking package correctly: * mainArticle = full blog post content * coverLetter = 216-word SIR— letter * Links to blog post via blogPostId - Archive 'Letter to The Economist' from blog posts (it's the cover letter) - Fix date display on article cards (use published_at) - Target publication already displaying via blue badge Database changes: - Make blogPostId optional in SubmissionTracking model - Economist package ID: 68fa85ae49d4900e7f2ecd83 - Le Monde package ID: 68fa2abd2e6acd5691932150 Next: Enhanced modal with tabs, validation, export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
507 lines
15 KiB
JavaScript
Executable file
507 lines
15 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Hook Validator: Bash Command
|
|
*
|
|
* Runs BEFORE Bash tool execution to enforce governance requirements.
|
|
* This is architectural enforcement - AI cannot bypass this check.
|
|
*
|
|
* Checks:
|
|
* 1. Deployment patterns (inst_025: separate rsync per directory level)
|
|
* 2. Permission requirements (inst_020, inst_022: chmod flags)
|
|
* 3. CrossReferenceValidator (instruction conflicts with command)
|
|
* 4. Pre-action-check enforcement
|
|
*
|
|
* Exit codes:
|
|
* 0 = PASS (allow command)
|
|
* 1 = FAIL (block command)
|
|
*
|
|
* Copyright 2025 Tractatus Project
|
|
* Licensed under Apache License 2.0
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// Import CrossReferenceValidator
|
|
const CrossReferenceValidator = require('../framework-components/CrossReferenceValidator.js');
|
|
|
|
// Hooks receive input via stdin as JSON
|
|
let HOOK_INPUT = null;
|
|
let BASH_COMMAND = null;
|
|
|
|
const SESSION_STATE_PATH = path.join(__dirname, '../../.claude/session-state.json');
|
|
const INSTRUCTION_HISTORY_PATH = path.join(__dirname, '../../.claude/instruction-history.json');
|
|
const METRICS_PATH = path.join(__dirname, '../../.claude/metrics/hooks-metrics.json');
|
|
|
|
/**
|
|
* Color output
|
|
*/
|
|
const colors = {
|
|
reset: '\x1b[0m',
|
|
green: '\x1b[32m',
|
|
yellow: '\x1b[33m',
|
|
red: '\x1b[31m',
|
|
cyan: '\x1b[36m',
|
|
bold: '\x1b[1m'
|
|
};
|
|
|
|
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');
|
|
}
|
|
|
|
function header(message) {
|
|
log(`\n${colors.cyan}${colors.bold}[BASH VALIDATOR]${colors.reset} ${message}`, 'cyan');
|
|
}
|
|
|
|
/**
|
|
* Parse Bash command for rsync pattern analysis
|
|
*/
|
|
function parseRsyncCommand(command) {
|
|
// Match rsync commands
|
|
const rsyncMatch = command.match(/rsync\s+([^|&;]*)/);
|
|
if (!rsyncMatch) return null;
|
|
|
|
const fullCommand = rsyncMatch[1];
|
|
|
|
// Extract source files (before remote:)
|
|
const parts = fullCommand.split(/\s+(?=\w+@|ubuntu@)/);
|
|
if (parts.length < 2) return null;
|
|
|
|
const sourceSection = parts[0];
|
|
const targetSection = parts[1];
|
|
|
|
// Extract all file paths from source section (exclude flags)
|
|
const sourcePaths = sourceSection
|
|
.split(/\s+/)
|
|
.filter(part => !part.startsWith('-') && !part.startsWith('/dev/') && part.includes('/'))
|
|
.filter(part => !part.includes('--'));
|
|
|
|
// Extract target path
|
|
const targetMatch = targetSection.match(/[^:]+:(.+)/);
|
|
const targetPath = targetMatch ? targetMatch[1] : null;
|
|
|
|
return {
|
|
sourcePaths,
|
|
targetPath,
|
|
fullCommand
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check inst_025: Deployment directory structure
|
|
*/
|
|
function checkDeploymentDirectoryStructure() {
|
|
if (!BASH_COMMAND.includes('rsync')) {
|
|
return { passed: true };
|
|
}
|
|
|
|
// Exclude dry-run commands (testing is fine)
|
|
if (BASH_COMMAND.includes('-n') || BASH_COMMAND.includes('--dry-run')) {
|
|
return { passed: true };
|
|
}
|
|
|
|
const parsed = parseRsyncCommand(BASH_COMMAND);
|
|
if (!parsed) {
|
|
return { passed: true };
|
|
}
|
|
|
|
const { sourcePaths, targetPath } = parsed;
|
|
|
|
// If only one source file, no directory flattening risk
|
|
if (sourcePaths.length <= 1) {
|
|
return { passed: true };
|
|
}
|
|
|
|
// Check if source files have different directory structures
|
|
const directories = sourcePaths.map(p => {
|
|
const dir = path.dirname(p);
|
|
return dir;
|
|
});
|
|
|
|
// Check for different subdirectory levels
|
|
const uniqueDirs = [...new Set(directories)];
|
|
|
|
if (uniqueDirs.length > 1) {
|
|
// Multiple different directories - potential flattening risk
|
|
const violations = [];
|
|
|
|
// Analyze directory depth differences
|
|
const depths = uniqueDirs.map(dir => dir.split('/').length);
|
|
const minDepth = Math.min(...depths);
|
|
const maxDepth = Math.max(...depths);
|
|
|
|
if (maxDepth > minDepth) {
|
|
violations.push({
|
|
issue: 'Directory structure mismatch',
|
|
details: `Source files from different directory levels (depth ${minDepth}-${maxDepth})`,
|
|
sources: sourcePaths,
|
|
target: targetPath,
|
|
instruction: 'inst_025'
|
|
});
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
error('inst_025 VIOLATION: Deployment directory structure error');
|
|
log('');
|
|
violations.forEach(v => {
|
|
error(`Issue: ${v.issue}`);
|
|
error(`Details: ${v.details}`);
|
|
log('');
|
|
log(' Source files:', 'yellow');
|
|
v.sources.forEach(s => log(` - ${s}`, 'yellow'));
|
|
log(` Target: ${v.target}`, 'yellow');
|
|
log('');
|
|
error('This will flatten directory structure!');
|
|
log('');
|
|
log(' inst_025 requires:', 'cyan');
|
|
log(' "When source files have different subdirectories,', 'cyan');
|
|
log(' use SEPARATE rsync commands for each directory level"', 'cyan');
|
|
log('');
|
|
log(' Correct approach:', 'green');
|
|
v.sources.forEach((s, i) => {
|
|
const dir = path.dirname(s);
|
|
const filename = path.basename(s);
|
|
log(` ${i + 1}. rsync ... ${s} remote:${dir}/`, 'green');
|
|
});
|
|
});
|
|
|
|
return {
|
|
passed: false,
|
|
violations,
|
|
instruction: 'inst_025',
|
|
message: 'Multiple source files from different directories will flatten structure'
|
|
};
|
|
}
|
|
}
|
|
|
|
return { passed: true };
|
|
}
|
|
|
|
/**
|
|
* Check inst_022: rsync chmod flags for permissions
|
|
*/
|
|
function checkRsyncPermissions() {
|
|
if (!BASH_COMMAND.includes('rsync')) {
|
|
return { passed: true };
|
|
}
|
|
|
|
// Check if deploying to production
|
|
if (!BASH_COMMAND.includes('vps-93a693da') && !BASH_COMMAND.includes('/var/www/')) {
|
|
return { passed: true };
|
|
}
|
|
|
|
// Exclude dry-run
|
|
if (BASH_COMMAND.includes('-n') || BASH_COMMAND.includes('--dry-run')) {
|
|
return { passed: true };
|
|
}
|
|
|
|
// Check for --chmod flag
|
|
if (!BASH_COMMAND.includes('--chmod')) {
|
|
warning('inst_022: rsync to production without --chmod flag');
|
|
warning('Recommended: --chmod=D755,F644');
|
|
warning('This is a warning, not blocking (but should be fixed)');
|
|
return { passed: true }; // Warning only, not blocking
|
|
}
|
|
|
|
return { passed: true };
|
|
}
|
|
|
|
/**
|
|
* Check inst_064: File write redirects (prevent bash bypass of Write tool)
|
|
*/
|
|
function checkFileWriteRedirects() {
|
|
// Patterns that write to files using bash redirects
|
|
const redirectPatterns = [
|
|
{ pattern: /cat\s+>\s*[^\s|&;]+/, description: 'cat > file' },
|
|
{ pattern: /cat\s+>>\s*[^\s|&;]+/, description: 'cat >> file' },
|
|
{ pattern: /cat\s*<<\s*['"]?EOF['"]?/, description: 'cat << EOF > file (heredoc)' },
|
|
{ pattern: /echo\s+.*>\s*[^\s|&;]+/, description: 'echo > file' },
|
|
{ pattern: /echo\s+.*>>\s*[^\s|&;]+/, description: 'echo >> file' },
|
|
{ pattern: /printf\s+.*>\s*[^\s|&;]+/, description: 'printf > file' },
|
|
{ pattern: /tee\s+[^\s|&;]+/, description: 'tee file' }
|
|
];
|
|
|
|
// Exception: Allow redirects to /dev/null or stderr/stdout
|
|
if (BASH_COMMAND.includes('> /dev/null') ||
|
|
BASH_COMMAND.includes('2>&1') ||
|
|
BASH_COMMAND.includes('1>&2')) {
|
|
return { passed: true };
|
|
}
|
|
|
|
// Check for redirect patterns
|
|
for (const { pattern, description } of redirectPatterns) {
|
|
if (pattern.test(BASH_COMMAND)) {
|
|
error('inst_064 VIOLATION: Bash file write detected');
|
|
log('');
|
|
error(`Pattern: ${description}`);
|
|
log('');
|
|
log(' inst_064 requires:', 'cyan');
|
|
log(' "File operations MUST use Write tool (creation),', 'cyan');
|
|
log(' Edit tool (modification), Read tool (reading).', 'cyan');
|
|
log(' Bash tool is for terminal operations ONLY."', 'cyan');
|
|
log('');
|
|
log(' Correct approach:', 'green');
|
|
log(' Instead of: cat > file.txt << EOF', 'red');
|
|
log(' Use Write tool:', 'green');
|
|
log(' Write({', 'green');
|
|
log(' file_path: "/path/to/file.txt",', 'green');
|
|
log(' content: "your content here"', 'green');
|
|
log(' })', 'green');
|
|
log('');
|
|
error('This prevents bypassing Write tool hook validation');
|
|
log('');
|
|
|
|
return {
|
|
passed: false,
|
|
violations: [{
|
|
issue: 'Bash file write redirect detected',
|
|
pattern: description,
|
|
instruction: 'inst_064'
|
|
}],
|
|
instruction: 'inst_064',
|
|
message: `Bash file write operations must use Write tool instead: ${description}`
|
|
};
|
|
}
|
|
}
|
|
|
|
return { passed: true };
|
|
}
|
|
|
|
/**
|
|
* Check pre-action-check requirement (inst_038)
|
|
*/
|
|
function checkPreActionCheckRecency() {
|
|
// Only enforce for deployment commands
|
|
if (!BASH_COMMAND.includes('rsync') && !BASH_COMMAND.includes('deploy')) {
|
|
return { passed: true };
|
|
}
|
|
|
|
// Check if pre-action-check was run recently
|
|
let sessionState;
|
|
try {
|
|
if (!fs.existsSync(SESSION_STATE_PATH)) {
|
|
return { passed: true }; // No session state, can't verify
|
|
}
|
|
sessionState = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8'));
|
|
} catch (error) {
|
|
warning(`Could not load session state: ${error.message}`);
|
|
return { passed: true };
|
|
}
|
|
|
|
const lastPreActionCheck = sessionState.last_pre_action_check;
|
|
const currentAction = sessionState.action_count || 0;
|
|
|
|
if (!lastPreActionCheck) {
|
|
warning('No pre-action-check recorded in this session');
|
|
warning('Consider running: node scripts/pre-action-check.js deployment "<command>"');
|
|
return { passed: true }; // Warning only for now
|
|
}
|
|
|
|
// Check if pre-action-check is recent (within last 5 actions)
|
|
if (currentAction - lastPreActionCheck > 5) {
|
|
warning(`Pre-action-check last run ${currentAction - lastPreActionCheck} actions ago`);
|
|
warning('Consider running: node scripts/pre-action-check.js deployment "<command>"');
|
|
}
|
|
|
|
return { passed: true };
|
|
}
|
|
|
|
/**
|
|
* Update session state with validation
|
|
*/
|
|
function updateSessionState(validationResult) {
|
|
try {
|
|
let sessionState = {};
|
|
if (fs.existsSync(SESSION_STATE_PATH)) {
|
|
sessionState = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8'));
|
|
}
|
|
|
|
if (!sessionState.framework_components) {
|
|
sessionState.framework_components = {};
|
|
}
|
|
|
|
if (!sessionState.framework_components.BashCommandValidator) {
|
|
sessionState.framework_components.BashCommandValidator = {
|
|
message: 0,
|
|
tokens: 0,
|
|
timestamp: null,
|
|
last_validation: null,
|
|
validations_performed: 0,
|
|
blocks_issued: 0
|
|
};
|
|
}
|
|
|
|
sessionState.framework_components.BashCommandValidator.last_validation = new Date().toISOString();
|
|
sessionState.framework_components.BashCommandValidator.validations_performed += 1;
|
|
if (!validationResult.passed) {
|
|
sessionState.framework_components.BashCommandValidator.blocks_issued += 1;
|
|
}
|
|
|
|
// Increment action count
|
|
sessionState.action_count = (sessionState.action_count || 0) + 1;
|
|
|
|
fs.writeFileSync(SESSION_STATE_PATH, JSON.stringify(sessionState, null, 2));
|
|
} catch (error) {
|
|
warning(`Could not update session state: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update hook metrics
|
|
*/
|
|
function updateMetrics(result) {
|
|
try {
|
|
let metrics = { executions: [], session_stats: {} };
|
|
|
|
if (fs.existsSync(METRICS_PATH)) {
|
|
metrics = JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8'));
|
|
}
|
|
|
|
if (!metrics.executions) {
|
|
metrics.executions = [];
|
|
}
|
|
|
|
const execution = {
|
|
hook: 'validate-bash-command',
|
|
timestamp: new Date().toISOString(),
|
|
command: BASH_COMMAND.substring(0, 100), // First 100 chars
|
|
result: result.passed ? 'passed' : 'blocked'
|
|
};
|
|
|
|
if (!result.passed) {
|
|
execution.reason = result.message;
|
|
execution.instruction = result.instruction;
|
|
}
|
|
|
|
metrics.executions.push(execution);
|
|
|
|
// Update session stats
|
|
if (!metrics.session_stats) {
|
|
metrics.session_stats = {};
|
|
}
|
|
metrics.session_stats.total_bash_hooks = (metrics.session_stats.total_bash_hooks || 0) + 1;
|
|
if (!result.passed) {
|
|
metrics.session_stats.total_bash_blocks = (metrics.session_stats.total_bash_blocks || 0) + 1;
|
|
}
|
|
metrics.session_stats.last_updated = new Date().toISOString();
|
|
|
|
// Keep only last 1000 executions
|
|
if (metrics.executions.length > 1000) {
|
|
metrics.executions = metrics.executions.slice(-1000);
|
|
}
|
|
|
|
fs.writeFileSync(METRICS_PATH, JSON.stringify(metrics, null, 2));
|
|
} catch (error) {
|
|
warning(`Could not update metrics: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check CrossReferenceValidator (automatic invocation)
|
|
*/
|
|
function checkCrossReferenceValidator() {
|
|
try {
|
|
const validator = new CrossReferenceValidator({ silent: false });
|
|
const result = validator.validate('bash', { command: BASH_COMMAND });
|
|
|
|
if (!result.passed) {
|
|
return {
|
|
passed: false,
|
|
message: result.conflicts.map(c => c.issue).join('; '),
|
|
instruction: result.conflicts[0]?.instruction || 'unknown'
|
|
};
|
|
}
|
|
|
|
return { passed: true };
|
|
} catch (error) {
|
|
warning(`CrossReferenceValidator error: ${error.message}`);
|
|
return { passed: true }; // Don't block on errors
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main validation function
|
|
*/
|
|
function main() {
|
|
header('Bash Command Pre-Execution Check');
|
|
|
|
// Read stdin for hook input
|
|
try {
|
|
const stdin = fs.readFileSync(0, 'utf-8');
|
|
HOOK_INPUT = JSON.parse(stdin);
|
|
BASH_COMMAND = HOOK_INPUT.tool_input?.command;
|
|
|
|
if (!BASH_COMMAND) {
|
|
error('No Bash command in hook input');
|
|
process.exit(1);
|
|
}
|
|
|
|
log(`Command: ${BASH_COMMAND.substring(0, 80)}${BASH_COMMAND.length > 80 ? '...' : ''}`, 'cyan');
|
|
log('');
|
|
|
|
} catch (error) {
|
|
error(`Failed to parse hook input: ${error.message}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
// Run checks
|
|
const checks = [
|
|
{ name: 'File write redirects (inst_064)', fn: checkFileWriteRedirects },
|
|
{ name: 'CrossReferenceValidator (instruction conflicts)', fn: checkCrossReferenceValidator },
|
|
{ name: 'Deployment directory structure (inst_025)', fn: checkDeploymentDirectoryStructure },
|
|
{ name: 'Rsync permissions (inst_022)', fn: checkRsyncPermissions },
|
|
{ name: 'Pre-action-check recency (inst_038)', fn: checkPreActionCheckRecency }
|
|
];
|
|
|
|
let allPassed = true;
|
|
let failedCheck = null;
|
|
|
|
for (const check of checks) {
|
|
log(`Checking: ${check.name}`, 'cyan');
|
|
const result = check.fn();
|
|
|
|
if (result.passed) {
|
|
success('PASS');
|
|
} else {
|
|
error('FAIL');
|
|
allPassed = false;
|
|
failedCheck = result;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Update session state and metrics
|
|
updateSessionState({ passed: allPassed });
|
|
updateMetrics({ passed: allPassed, message: failedCheck?.message, instruction: failedCheck?.instruction });
|
|
|
|
log('');
|
|
|
|
if (allPassed) {
|
|
success('✓ All checks passed - command allowed');
|
|
process.exit(0);
|
|
} else {
|
|
log('');
|
|
error('✗ COMMAND BLOCKED - Governance violation detected');
|
|
error(`Violates: ${failedCheck.instruction}`);
|
|
log('');
|
|
error('Fix the command and try again.');
|
|
log('');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Run validator
|
|
main();
|