config-audit/scanners/fix-engine.mjs
Kjell Tore Guttormsen 3d6ddb273c fix(scanners): the fix engine downgraded a near-miss on xhigh to high (C2)
`fix-engine.mjs` carried its own copy of the valid `effortLevel` list, and
that copy had gone stale on `xhigh` (CC 2.1.154's top Opus tier) while
`settings-validator.mjs` had all five. The nearest-match "fix" therefore
corrected `xhig` — and `XHIGH` — to `high`: the tool silently changed the
tier the user asked for, in the one code path whose whole job is to write
the corrected value back to disk.

Fixed by sharing the validator's table instead of aligning the copy, so the
two cannot drift again. Guard asserts the blanket invariant — a one-character
near-miss on EVERY valid tier corrects back to that same tier — rather than
pinning the one level that happened to be missing; verified red against the
original stale array.

Suite 1488/0. Frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P6Rh59Mtj4uYrdYMCYZJE
2026-08-09 21:29:00 +02:00

684 lines
20 KiB
JavaScript

/**
* 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';
import { VALID_EFFORT_LEVELS as SETTINGS_EFFORT_LEVELS } from './settings-validator.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 — the validator's list, not a copy. */
const VALID_EFFORT_LEVELS = [...SETTINGS_EFFORT_LEVELS];
/**
* 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), but a file-rename always
// sorts after every other fix. A rename moves the file out from under any
// later fix that still addresses the old path: a rule file with both
// `globs:` and a non-.md extension had the rename applied first, and the
// frontmatter fix then failed with ENOENT while the run still exited 0.
// `?? 4`, not `|| 4`: critical weighs 0, and `0 || 4` evaluates to 4 — so
// critical fixes sorted LAST, the exact opposite of this function's contract
// (M-BUG-30). The old test used the same falsy fallback and agreed with the bug.
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
fixes.sort((a, b) => {
const aRename = a.type === FIX_TYPES.FILE_RENAME ? 1 : 0;
const bRename = b.type === FIX_TYPES.FILE_RENAME ? 1 : 0;
if (aRename !== bRename) return aRename - bRename;
return (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 "globs" instead of documented "paths"') {
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()
* @param {object} [opts]
* @param {boolean} [opts.includeGlobal=false] - Must match the scope the fix run scanned
* @returns {Promise<{ verified: string[], regressions: string[], newFindings: object[] }>}
*/
export async function verifyFixes(originalEnvelope, appliedResults, opts = {}) {
const targetPath = originalEnvelope.meta.target;
const verified = [];
const regressions = [];
const newFindings = [];
// Re-scan the target in the SAME scope the fix run used. This was hardcoded
// to includeGlobal:false: after a --global run, every global-scope finding
// was absent from the re-scan and therefore counted as verified — a clean
// "fixed" report for files nothing had touched.
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: opts.includeGlobal === true });
// 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 };