#!/usr/bin/env node /** * PostToolUse hook: verify config files after Edit/Write. * Runs the relevant single scanner on the edited file. * Blocks if new critical/high findings are introduced. * Timeout: 10 seconds (runs one scanner, not all 8). * Graceful degradation: returns {} (allow) on any error. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { basename, dirname, resolve, sep } from 'node:path'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; // Config file patterns (shared with auto-backup-config.mjs) const CONFIG_PATTERNS = [ { pattern: /CLAUDE\.md$/i, scanner: 'CML' }, { pattern: /CLAUDE\.local\.md$/i, scanner: 'CML' }, { pattern: /settings\.json$/, scanner: 'SET' }, { pattern: /settings\.local\.json$/, scanner: 'SET' }, { pattern: /hooks\.json$/, scanner: 'HKV' }, { pattern: /\.mcp\.json$/, scanner: 'MCP' }, ]; const RULES_DIR_PATTERN = /[/\\]rules[/\\]/; function detectScanner(filePath) { if (!filePath) return null; const name = basename(filePath); const dir = dirname(filePath); for (const { pattern, scanner } of CONFIG_PATTERNS) { if (pattern.test(name)) return scanner; } // Rules directory if ((RULES_DIR_PATTERN.test(dir) || dir.endsWith(`${sep}rules`)) && name.endsWith('.md')) { return 'RUL'; } return null; } function getCacheKey(filePath) { const hash = createHash('md5').update(filePath).digest('hex').slice(0, 8); return resolve(tmpdir(), `config-audit-last-scan-${hash}.json`); } function loadPreviousScan(cacheFile) { try { if (existsSync(cacheFile)) { return JSON.parse(readFileSync(cacheFile, 'utf-8')); } } catch { /* ignore */ } return null; } function saveScanResult(cacheFile, result) { try { writeFileSync(cacheFile, JSON.stringify(result), 'utf-8'); } catch { /* ignore */ } } /** * Read all data from stdin asynchronously. * @returns {Promise} */ function readStdin() { return new Promise((resolve, reject) => { const chunks = []; process.stdin.setEncoding('utf-8'); process.stdin.on('data', chunk => chunks.push(chunk)); process.stdin.on('end', () => resolve(chunks.join(''))); process.stdin.on('error', reject); // Safety: if no data arrives within 2s, resolve with empty string setTimeout(() => resolve(chunks.join('')), 2000); }); } function allow() { process.stdout.write('{}'); process.exit(0); } /** * Walk up from filePath to find a likely project root. */ function findProjectRoot(fp) { let dir = dirname(resolve(fp)); for (let i = 0; i < 10; i++) { if (existsSync(resolve(dir, '.git')) || existsSync(resolve(dir, 'CLAUDE.md'))) { return dir; } const parent = dirname(dir); if (parent === dir) break; dir = parent; } return dirname(resolve(fp)); } async function main() { // Read stdin let raw; try { raw = await readStdin(); } catch { allow(); return; } // Parse tool input let toolInput; try { toolInput = JSON.parse(raw); } catch { allow(); return; } const filePath = toolInput.file_path || toolInput.path; const scannerType = detectScanner(filePath); if (!scannerType || !filePath || !existsSync(filePath)) { allow(); return; } // Run the relevant scanner const projectDir = findProjectRoot(filePath); const { discoverConfigFiles } = await import('../../scanners/lib/file-discovery.mjs'); const { resetCounter } = await import('../../scanners/lib/output.mjs'); const scannerMap = { CML: () => import('../../scanners/claude-md-linter.mjs'), SET: () => import('../../scanners/settings-validator.mjs'), HKV: () => import('../../scanners/hook-validator.mjs'), RUL: () => import('../../scanners/rules-validator.mjs'), MCP: () => import('../../scanners/mcp-config-validator.mjs'), }; const loader = scannerMap[scannerType]; if (!loader) { allow(); return; } resetCounter(); const { scan } = await loader(); const discovery = await discoverConfigFiles(projectDir, { includeGlobal: false }); const result = await scan(projectDir, discovery); // Compare with previous scan const cacheFile = getCacheKey(filePath); const previous = loadPreviousScan(cacheFile); // Save current result saveScanResult(cacheFile, { criticalCount: result.counts.critical || 0, highCount: result.counts.high || 0, findingCount: result.findings.length, }); if (!previous) { allow(); return; } // Check if new critical/high findings were introduced const newCritical = (result.counts.critical || 0) - (previous.criticalCount || 0); const newHigh = (result.counts.high || 0) - (previous.highCount || 0); if (newCritical > 0 || newHigh > 0) { const parts = []; if (newCritical > 0) parts.push(`${newCritical} new critical`); if (newHigh > 0) parts.push(`${newHigh} new high`); const response = { decision: 'block', reason: `[config-audit] Edit introduced ${parts.join(' and ')} finding(s) in ${basename(filePath)}. Review with /config-audit posture`, }; process.stdout.write(JSON.stringify(response)); } else { process.stdout.write('{}'); } } main().catch(() => { // Graceful degradation — always allow on error process.stdout.write('{}'); process.exit(0); });