`write-scope.mjs` has existed since M-BUG-41, but only one writer ever called it. Measured 2026-08-12: 9 files under `scanners/` write to disk, 1 imported the gate; 21 command templates, 17 mention a write, 5 call `write-scope-cli`. Five templates paraphrasing one policy is the shape that put the lever table in five copies (#61) — one level up. The defect was never "8 ungated writers = 8 bugs". Four of them write the plugin's own bookkeeping and must STAY ungated: a gate that fires on every run gets switched off, and then it guards nothing. The defect is that nothing declared WHICH, so the question was answered by reading, and answered differently each time it was asked. `tests/lib/write-gate-coverage.test.mjs` makes the answer structural: every writer either imports the gate or holds an EXEMPT entry naming where the bytes land. Seen RED against today's tree before the fix (4 ungated writers), and each of its four assertions was separately seen red against its own defect. Two premises in the plan text were falsified by measuring them first: - `scan-orchestrator` was carried as "plugin-managed, legitimately exempt". `--save-baseline` derives its path from the SCAN TARGET, so `--global` lands `~/.claude/.config-audit-baseline.json` — user-scope, require-ok. It is gated. `lib/baseline.mjs` is the genuinely exempt one. - the first sweep scored 9 writers with a regex that could not match `writeFileSync(`, so `lib/backup.mjs` — a real writer — read as clean. The guard covers sync and async forms, strips comments before matching, and asserts non-emptiness so a regex that stops matching cannot make every other assertion vacuously green (#63, #64). Gated: fix-engine, rollback-engine, campaign-export-cli, scan-orchestrator. All five call sites share ONE reduction, `evaluateWriteTargets` — four copies of classify/strongestGate/dedup is the drift this exists to prevent. `campaign export` still DISCLOSES rather than refuses: cross-repo is by design there, and tightening it into a refusal would break the feature. A dry run is still not a write, so it is never gated (#63). A refusal is a verdict about a config that WAS examined, so it rides in the payload and keeps the 0/1/2 exit contract (#62) — and the verdict now reaches the success payload too, since stderr is discarded by `2>/dev/null` (F3's class). commands/fix.md carries `--approve-scope` from the answer the user gives, with the rule stated where it can be read: classifying is not approving. Dogfooded end to end: a target outside the session root refuses with zero bytes written, then applies under `--approve-scope`. Suite 1703 -> 1707/0. Frozen v5.0.0 + default-output snapshots: 0 changed files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pkn22uGCgk6QZA738zNmHL
734 lines
22 KiB
JavaScript
734 lines
22 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 { evaluateWriteTargets } from './lib/write-scope.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');
|
|
}
|
|
|
|
// Q1 — the scope gate, in code rather than in the command template's prose.
|
|
//
|
|
// A file-rename writes TWO paths: the source disappears and `newPath`
|
|
// appears. Classifying only `plan.file` would let a rename move a repo file
|
|
// to a machine-wide destination under a `silent` gate.
|
|
//
|
|
// `!dryRun` is load-bearing and is the same rule the subtraction axis
|
|
// settled (#63): the gate guards a WRITE, and a dry run is not one.
|
|
// `requiresApproval` is reported either way, so a caller planning a run still
|
|
// learns that approval will be owed before anything is applied.
|
|
const scope = evaluateWriteTargets(
|
|
fixPlans.flatMap((p) => (p.newPath ? [p.file, p.newPath] : [p.file])),
|
|
opts.repoRoot ?? null,
|
|
opts.home ? { home: opts.home } : {},
|
|
);
|
|
|
|
if (scope.requiresApproval && !opts.approveScope && !opts.dryRun) {
|
|
// A refused write is a VERDICT about a config that was examined, not a
|
|
// tool failure (#62) — the caller renders the disclosure and asks. Nothing
|
|
// is applied, and this is not an exit-3 situation.
|
|
return {
|
|
applied: [],
|
|
failed: [],
|
|
gate: scope.gate,
|
|
requiresApproval: true,
|
|
disclosures: scope.disclosures,
|
|
refused: fixPlans.map((plan) => ({
|
|
findingId: plan.findingId,
|
|
file: plan.file,
|
|
status: 'refused',
|
|
reason: 'scope-gate',
|
|
type: plan.type,
|
|
})),
|
|
};
|
|
}
|
|
|
|
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,
|
|
gate: scope.gate,
|
|
requiresApproval: scope.requiresApproval,
|
|
disclosures: scope.disclosures,
|
|
refused: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 the set of fixed finding INSTANCES. A finding ID names the check, so
|
|
// one check failing in two files yields two findings sharing an ID; keying on
|
|
// the ID alone marks both fixed when one was, and the untouched sibling — still
|
|
// present in the re-scan — is then reported as a regression (M-BUG-28).
|
|
const instanceKey = (findingId, file) => `${findingId}::${file || ''}`;
|
|
const fixedInstances = new Set(
|
|
appliedResults.filter(r => r.status === 'applied').map(r => instanceKey(r.findingId, r.file)),
|
|
);
|
|
|
|
// 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 (!fixedInstances.has(instanceKey(f.id, f.file))) 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 => instanceKey(r.findingId, r.file) === instanceKey(f.id, f.file),
|
|
);
|
|
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 };
|