/** * Config-Audit Fix Engine * Deterministic fix engine: maps scanner findings to concrete file changes. * Zero external dependencies. */ import { readFile, writeFile, rename, stat } from 'node:fs/promises'; import { dirname } from 'node:path'; import { parseJson, parseFrontmatter } from './lib/yaml-parser.mjs'; import { createBackup } from './lib/backup.mjs'; import { runAllScanners } from './scan-orchestrator.mjs'; /** * Fix type constants. */ const FIX_TYPES = { JSON_KEY_ADD: 'json-key-add', JSON_KEY_REMOVE: 'json-key-remove', JSON_KEY_TYPE_FIX: 'json-key-type-fix', JSON_RESTRUCTURE: 'json-restructure', FRONTMATTER_RENAME: 'frontmatter-rename', FILE_RENAME: 'file-rename', }; /** Valid effortLevel values for nearest-match */ const VALID_EFFORT_LEVELS = ['low', 'medium', 'high', 'max']; /** * Plan fixes from a scanner envelope. * @param {object} envelope - Full scanner envelope from scan-orchestrator * @returns {{ fixes: object[], skipped: object[], manual: object[] }} */ export function planFixes(envelope) { const fixes = []; const skipped = []; const manual = []; for (const scanner of envelope.scanners) { for (const finding of scanner.findings) { if (!finding.autoFixable) { manual.push({ findingId: finding.id, title: finding.title, file: finding.file, recommendation: finding.recommendation, }); continue; } const fixPlan = createFixPlan(finding); if (fixPlan) { fixes.push(fixPlan); } else { skipped.push(finding); } } } // Sort fixes by severity weight (critical first) const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; fixes.sort((a, b) => (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4)); return { fixes, skipped, manual }; } /** * Create a fix plan for a single finding. * @param {object} finding * @returns {object|null} */ function createFixPlan(finding) { if (!finding.file) return null; const base = { findingId: finding.id, file: finding.file, severity: finding.severity, description: '', before: null, after: null, type: null, }; const scanner = finding.scanner; const title = finding.title; // --- SET scanner fixes --- if (scanner === 'SET') { if (title === 'Missing $schema reference') { return { ...base, type: FIX_TYPES.JSON_KEY_ADD, description: 'Add $schema reference for IDE autocomplete', before: '(no $schema key)', after: '"$schema": "https://json.schemastore.org/claude-code-settings.json"', key: '$schema', value: 'https://json.schemastore.org/claude-code-settings.json', }; } if (title === 'Deprecated settings key') { const key = extractKeyFromEvidence(finding.evidence); if (!key) return null; return { ...base, type: FIX_TYPES.JSON_KEY_REMOVE, description: `Remove deprecated key "${key}"`, before: finding.evidence, after: '(key removed)', key, }; } if (title === 'Type mismatch in settings') { const key = extractKeyFromEvidence(finding.evidence); if (!key) return null; const expectedType = extractExpectedType(finding.description); return { ...base, type: FIX_TYPES.JSON_KEY_TYPE_FIX, description: `Fix type of "${key}" to ${expectedType}`, before: finding.evidence, after: `(converted to ${expectedType})`, key, expectedType, }; } if (title === 'Invalid effortLevel value') { return { ...base, type: FIX_TYPES.JSON_KEY_TYPE_FIX, description: 'Fix effortLevel to nearest valid value', before: finding.evidence, after: '(nearest valid effortLevel)', key: 'effortLevel', expectedType: 'effortLevel', }; } if (title === 'Hooks configured as array instead of object') { return { ...base, type: FIX_TYPES.JSON_RESTRUCTURE, description: 'Convert hooks from array to object format', before: '"hooks": [...]', after: '"hooks": { ... }', restructureType: 'hooks-array-to-object', }; } } // --- HKV scanner fixes --- if (scanner === 'HKV') { if (title === 'Matcher must be a string, not an object') { return { ...base, type: FIX_TYPES.JSON_RESTRUCTURE, description: 'Convert matcher from object to string', before: finding.evidence, after: '"matcher": "ToolName"', restructureType: 'matcher-object-to-string', event: extractEventFromDescription(finding.description), }; } if (title === 'Hook timeout must be a number') { return { ...base, type: FIX_TYPES.JSON_KEY_TYPE_FIX, description: 'Convert timeout to number', before: finding.evidence, after: '(parsed to number)', key: 'timeout', expectedType: 'number', event: extractEventFromDescription(finding.description), }; } } // --- RUL scanner fixes --- if (scanner === 'RUL') { if (title === 'Rule uses deprecated "globs" field') { return { ...base, type: FIX_TYPES.FRONTMATTER_RENAME, description: 'Rename "globs" to "paths" in frontmatter', before: 'globs:', after: 'paths:', oldField: 'globs', newField: 'paths', }; } if (title === 'Rule file is not .md') { const newPath = finding.file.replace(/\.[^.]+$/, '.md'); return { ...base, type: FIX_TYPES.FILE_RENAME, description: `Rename to .md extension`, before: finding.file, after: newPath, newPath, }; } } return null; } /** * Apply planned fixes to files. * @param {object[]} fixPlans - Array of fix plans from planFixes() * @param {object} opts * @param {boolean} [opts.dryRun=false] * @param {string} [opts.backupDir] - Required if not dryRun * @returns {Promise<{ applied: object[], failed: object[] }>} */ export async function applyFixes(fixPlans, opts = {}) { const applied = []; const failed = []; if (!opts.dryRun && !opts.backupDir) { throw new Error('backupDir is required when not in dryRun mode'); } for (const plan of fixPlans) { if (opts.dryRun) { applied.push({ findingId: plan.findingId, file: plan.file, status: 'dry-run', type: plan.type, description: plan.description, }); continue; } try { await applyFix(plan); applied.push({ findingId: plan.findingId, file: plan.file, status: 'applied', type: plan.type, description: plan.description, }); } catch (err) { failed.push({ findingId: plan.findingId, file: plan.file, status: 'failed', error: err.message, type: plan.type, }); } } return { applied, failed }; } /** * Apply a single fix. * @param {object} plan */ async function applyFix(plan) { switch (plan.type) { case FIX_TYPES.JSON_KEY_ADD: await applyJsonKeyAdd(plan); break; case FIX_TYPES.JSON_KEY_REMOVE: await applyJsonKeyRemove(plan); break; case FIX_TYPES.JSON_KEY_TYPE_FIX: await applyJsonKeyTypeFix(plan); break; case FIX_TYPES.JSON_RESTRUCTURE: await applyJsonRestructure(plan); break; case FIX_TYPES.FRONTMATTER_RENAME: await applyFrontmatterRename(plan); break; case FIX_TYPES.FILE_RENAME: await applyFileRename(plan); break; default: throw new Error(`Unknown fix type: ${plan.type}`); } } /** * Add a key to a JSON file (as first key for $schema). */ async function applyJsonKeyAdd(plan) { const content = await readFile(plan.file, 'utf-8'); const parsed = parseJson(content); if (parsed === null) throw new Error('Invalid JSON'); // For $schema, insert as first key if (plan.key === '$schema') { const newObj = { $schema: plan.value, ...parsed }; await writeJsonFile(plan.file, newObj); } else { parsed[plan.key] = plan.value; await writeJsonFile(plan.file, parsed); } } /** * Remove a key from a JSON file. */ async function applyJsonKeyRemove(plan) { const content = await readFile(plan.file, 'utf-8'); const parsed = parseJson(content); if (parsed === null) throw new Error('Invalid JSON'); delete parsed[plan.key]; await writeJsonFile(plan.file, parsed); } /** * Fix the type of a JSON key value. */ async function applyJsonKeyTypeFix(plan) { const content = await readFile(plan.file, 'utf-8'); const parsed = parseJson(content); if (parsed === null) throw new Error('Invalid JSON'); // Handle nested hook timeout fixes if (plan.key === 'timeout' && plan.event) { fixTimeoutInHooks(parsed, plan.event); await writeJsonFile(plan.file, parsed); return; } // Handle effortLevel special case if (plan.key === 'effortLevel' && plan.expectedType === 'effortLevel') { parsed.effortLevel = findNearestEffortLevel(parsed.effortLevel); await writeJsonFile(plan.file, parsed); return; } // Generic type conversion if (parsed[plan.key] !== undefined) { parsed[plan.key] = convertType(parsed[plan.key], plan.expectedType); await writeJsonFile(plan.file, parsed); } } /** * Restructure JSON (hooks array→object, matcher object→string). */ async function applyJsonRestructure(plan) { const content = await readFile(plan.file, 'utf-8'); const parsed = parseJson(content); if (parsed === null) throw new Error('Invalid JSON'); if (plan.restructureType === 'hooks-array-to-object') { restructureHooksArrayToObject(parsed); await writeJsonFile(plan.file, parsed); return; } if (plan.restructureType === 'matcher-object-to-string') { restructureMatcherObjectToString(parsed, plan.event); await writeJsonFile(plan.file, parsed); return; } throw new Error(`Unknown restructure type: ${plan.restructureType}`); } /** * Rename a frontmatter field in a markdown file. */ async function applyFrontmatterRename(plan) { const content = await readFile(plan.file, 'utf-8'); // Replace the field name in the frontmatter section only const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/); if (!fmMatch) throw new Error('No frontmatter found'); const before = fmMatch[2]; const regex = new RegExp(`^(${plan.oldField})(\\s*:)`, 'gm'); const after = before.replace(regex, `${plan.newField}$2`); if (before === after) throw new Error(`Field "${plan.oldField}" not found in frontmatter`); const newContent = fmMatch[1] + after + fmMatch[3] + content.slice(fmMatch[0].length); await writeFile(plan.file, newContent, 'utf-8'); // Validate frontmatter still parses const { frontmatter } = parseFrontmatter(newContent); if (!frontmatter) throw new Error('Frontmatter parse failed after rename'); } /** * Rename a file (change extension to .md). */ async function applyFileRename(plan) { try { await stat(plan.file); } catch { throw new Error(`Source file not found: ${plan.file}`); } // Check target doesn't already exist try { await stat(plan.newPath); throw new Error(`Target already exists: ${plan.newPath}`); } catch (err) { if (err.message.startsWith('Target already exists')) throw err; // File doesn't exist — good } await rename(plan.file, plan.newPath); } // --- Helper functions --- /** * Write a JSON object to a file with 2-space indent. */ async function writeJsonFile(filePath, obj) { const json = JSON.stringify(obj, null, 2) + '\n'; // Validate the JSON we're about to write const reparsed = parseJson(json); if (reparsed === null) throw new Error('Generated invalid JSON'); await writeFile(filePath, json, 'utf-8'); } /** * Convert a value to the expected type. */ function convertType(value, expectedType) { switch (expectedType) { case 'boolean': if (typeof value === 'string') { if (value.toLowerCase() === 'true' || value === '1') return true; if (value.toLowerCase() === 'false' || value === '0') return false; } if (typeof value === 'number') return value !== 0; return Boolean(value); case 'number': if (typeof value === 'string') { const num = Number(value); return isNaN(num) ? 10000 : num; // default 10000 for timeouts } return Number(value); case 'string': return String(value); default: return value; } } /** * Find the nearest valid effortLevel. */ function findNearestEffortLevel(value) { if (typeof value !== 'string') return 'medium'; const lower = value.toLowerCase(); // Simple distance-based matching let best = 'medium'; let bestDist = Infinity; for (const level of VALID_EFFORT_LEVELS) { const dist = levenshtein(lower, level); if (dist < bestDist) { bestDist = dist; best = level; } } return best; } /** * Simple Levenshtein distance. */ function levenshtein(a, b) { const m = a.length, n = b.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = Math.min( dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0), ); } } return dp[m][n]; } /** * Convert hooks array to object format. * Best-effort: wraps array items under "PreToolUse" if they have event fields, * otherwise groups by event property. */ function restructureHooksArrayToObject(parsed) { if (!Array.isArray(parsed.hooks)) return; const hooksObj = {}; for (const item of parsed.hooks) { const event = item.event || 'PreToolUse'; if (!hooksObj[event]) hooksObj[event] = []; // Build the handler group const group = {}; if (item.matcher) group.matcher = typeof item.matcher === 'string' ? item.matcher : String(item.matcher); group.hooks = []; if (item.command) { group.hooks.push({ type: item.type || 'command', command: item.command, ...(item.timeout !== undefined ? { timeout: typeof item.timeout === 'number' ? item.timeout : Number(item.timeout) || 10000 } : {}), }); } else if (item.hooks && Array.isArray(item.hooks)) { group.hooks = item.hooks; } hooksObj[event].push(group); } parsed.hooks = hooksObj; } /** * Convert matcher from object to string in hooks config. */ function restructureMatcherObjectToString(parsed, event) { const hooks = parsed.hooks || parsed; if (typeof hooks !== 'object' || Array.isArray(hooks)) return; for (const [eventKey, handlers] of Object.entries(hooks)) { if (event && eventKey !== event) continue; if (!Array.isArray(handlers)) continue; for (const group of handlers) { if (group.matcher && typeof group.matcher === 'object') { // Extract tool name from object — common patterns: { tool: "Bash" }, { name: "Bash" } const tool = group.matcher.tool || group.matcher.name || group.matcher.type || Object.values(group.matcher)[0]; group.matcher = typeof tool === 'string' ? tool : 'Bash'; } } } } /** * Fix timeout type in nested hooks config. */ function fixTimeoutInHooks(parsed, event) { const hooks = parsed.hooks || parsed; if (typeof hooks !== 'object' || Array.isArray(hooks)) return; for (const [eventKey, handlers] of Object.entries(hooks)) { if (event && eventKey !== event) continue; if (!Array.isArray(handlers)) continue; for (const group of handlers) { if (!group.hooks || !Array.isArray(group.hooks)) continue; for (const hook of group.hooks) { if (hook.timeout !== undefined && typeof hook.timeout !== 'number') { const num = Number(hook.timeout); hook.timeout = isNaN(num) ? 10000 : num; } } } } } /** * Extract key name from evidence string like: 'someKey: "value"' */ function extractKeyFromEvidence(evidence) { if (!evidence) return null; const match = evidence.match(/^(\w+)\s*:/); return match ? match[1] : null; } /** * Extract expected type from description string like: 'should be boolean, got string' */ function extractExpectedType(description) { const match = description.match(/should be (\w+)/); return match ? match[1] : 'string'; } /** * Extract event name from description like: '"PreToolUse" has a matcher...' */ function extractEventFromDescription(description) { const match = description.match(/"(\w+)"/); return match ? match[1] : null; } /** * Verify fixes by re-running affected scanners. * @param {object} originalEnvelope - Original scanner envelope * @param {object[]} appliedResults - Results from applyFixes() * @returns {Promise<{ verified: string[], regressions: string[], newFindings: object[] }>} */ export async function verifyFixes(originalEnvelope, appliedResults) { const targetPath = originalEnvelope.meta.target; const verified = []; const regressions = []; const newFindings = []; // Re-scan the target const newEnvelope = await runAllScanners(targetPath, { includeGlobal: false }); // Build set of original finding IDs that were fixed const fixedIds = new Set( appliedResults.filter(r => r.status === 'applied').map(r => r.findingId), ); // Build set of new finding titles for comparison const newFindingMap = new Map(); for (const scanner of newEnvelope.scanners) { for (const f of scanner.findings) { newFindingMap.set(`${f.scanner}:${f.title}:${f.file}`, f); } } // Check that fixed findings are gone for (const scanner of originalEnvelope.scanners) { for (const f of scanner.findings) { if (!fixedIds.has(f.id)) continue; const key = `${f.scanner}:${f.title}:${f.file}`; // For file-rename fixes, the original file path won't exist anymore const fixResult = appliedResults.find(r => r.findingId === f.id); if (fixResult && fixResult.type === 'file-rename') { // Check that the finding doesn't reappear at the new path verified.push(f.id); continue; } if (newFindingMap.has(key)) { regressions.push(f.id); } else { verified.push(f.id); } } } // Check for any completely new findings not in original const originalKeys = new Set(); for (const scanner of originalEnvelope.scanners) { for (const f of scanner.findings) { originalKeys.add(`${f.scanner}:${f.title}:${f.file}`); } } for (const [key, f] of newFindingMap) { if (!originalKeys.has(key)) { newFindings.push(f); } } return { verified, regressions, newFindings }; } export { FIX_TYPES };