ktg-plugin-marketplace/plugins/config-audit/hooks/scripts/post-edit-verify.mjs

191 lines
5.2 KiB
JavaScript

#!/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<string>}
*/
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);
});