config-audit/scanners/conflict-detector.mjs
Kjell Tore Guttormsen e8afb148d3 fix(acr): conflict-detector segregates plugin-bundled configs (M-BUG-2)
CNF compared every discovered settings.json/hooks.json pairwise regardless of
origin, so it treated installed plugins' bundled configs — each plugin's own
settings.json/hooks.json plus its shipped test fixtures and examples under
~/.claude/plugins/ — as if they were the user's authored cascade. A "conflict"
between two plugins' bundled test fixtures is not something a user can resolve,
yet these dominated the count: 339 CNF findings on this machine (315 high-sev
permission allow/deny "conflicts", 18 duplicate-hook, 6 settings-key), almost all
sourced from plugin-internal fixtures. The Conflicts grade was F on pure noise.
Fix: CNF excludes any file whose path is under `.claude/plugins/` from conflict
analysis (new isPluginBundled predicate; absPath marker). Kept CNF-local rather
than a discovery-level skip on purpose: an active plugin's contributed
hooks.json/.mcp.json legitimately lives in plugins/cache and other scanners need
it — only conflict analysis must ignore plugin-bundled files. Same class as
M-BUG-8 (non-live config trees treated as live). Suite 1344/0 (+3: plugin-bundled
exclusion, discovery-side sanity, over-exclusion guard). Frozen v5.0.0 + SC-5
snapshots untouched (marketplace-medium has no plugins/ paths), no re-seed.
Dogfood ~/.claude CNF 339->0 (F-grade was 100% plugin-bundled noise; the ~3
genuine user-scope local settings have no actual conflicting keys, matching the
plan C5 "real surface ~3 files" prediction).

Follow-up (not in this fix): classifyScope tags plugin-bundled files by checking
basePath instead of the file's own path, so scope:'plugin' is effectively dead
for a ~/.claude-rooted scan. Fixing it would let every scanner trust the scope
field, but that is a discovery-layer change beyond this bug's scope.
2026-06-26 17:24:08 +02:00

240 lines
8.6 KiB
JavaScript

/**
* CNF Scanner — Conflict Detector
* Detects conflicts between config files at different hierarchy levels:
* settings key conflicts, permission contradictions, hook duplicates.
* Finding IDs: CA-CNF-NNN
*/
import { sep } from 'node:path';
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseJson } from './lib/yaml-parser.mjs';
import { truncate } from './lib/string-utils.mjs';
import { rulesIntersect } from './lib/permission-rules.mjs';
const SCANNER = 'CNF';
// Keys checked separately or not meaningful to compare
const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']);
// Files under `.claude/plugins/` are shipped by installed plugins — the plugin's
// own settings.json/hooks.json plus bundled test fixtures and examples. They are
// not the user's authored cascade and a "conflict" between them is not something
// the user can resolve, so they must be excluded from cross-scope conflict
// analysis. (Other scanners still need active plugin config, so this exclusion is
// CNF-local, not a discovery-level skip. M-BUG-2.)
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
/**
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
* @returns {boolean} true if the file is shipped by an installed plugin
*/
function isPluginBundled(file) {
return file.absPath.includes(PLUGIN_TREE_MARKER);
}
/**
* Flatten an object's top-level keys into a simple key→value map.
* Only first level — we compare top-level settings, not nested.
* @param {object} obj
* @returns {Map<string, string>} key → JSON-stringified value
*/
function flattenTopLevel(obj) {
const map = new Map();
for (const [key, value] of Object.entries(obj)) {
if (!SKIP_KEYS.has(key)) {
map.set(key, JSON.stringify(value));
}
}
return map;
}
/**
* Collect hooks from a parsed settings or hooks.json object.
* @param {object} parsed
* @returns {{ event: string, matcher: string }[]}
*/
function collectHooks(parsed) {
const hooks = parsed.hooks || parsed;
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return [];
const result = [];
for (const [event, handlers] of Object.entries(hooks)) {
if (!Array.isArray(handlers)) continue;
for (const handler of handlers) {
const matcher = typeof handler.matcher === 'string' ? handler.matcher : '*';
result.push({ event, matcher });
}
}
return result;
}
/**
* Scan for conflicts across configuration scopes.
* @param {string} targetPath
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
* @returns {Promise<object>}
*/
export async function scan(targetPath, discovery) {
const start = Date.now();
const findings = [];
// Collect settings files (excluding plugin-bundled — see PLUGIN_TREE_MARKER)
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json' && !isPluginBundled(f));
// Collect hooks files (excluding plugin-bundled)
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json' && !isPluginBundled(f));
const totalFiles = settingsFiles.length + hooksFiles.length;
// Need at least 2 files to detect conflicts
if (settingsFiles.length < 2 && (settingsFiles.length + hooksFiles.length) < 2) {
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
}
// --- Settings key conflicts ---
const settingsByScope = []; // [{ scope, file, keys: Map<key, jsonValue> }]
for (const file of settingsFiles) {
const content = await readTextFile(file.absPath);
if (!content) continue;
const parsed = parseJson(content);
if (!parsed) continue;
settingsByScope.push({
scope: file.scope,
file: file.relPath,
absPath: file.absPath,
keys: flattenTopLevel(parsed),
raw: parsed,
});
}
// Compare keys across scopes
if (settingsByScope.length >= 2) {
const allKeys = new Set();
for (const s of settingsByScope) {
for (const key of s.keys.keys()) allKeys.add(key);
}
for (const key of allKeys) {
const scopesWithKey = settingsByScope.filter(s => s.keys.has(key));
if (scopesWithKey.length < 2) continue;
// Check if values differ
const values = new Set(scopesWithKey.map(s => s.keys.get(key)));
if (values.size > 1) {
const details = scopesWithKey
.map(s => `${s.scope} (${s.file}): ${truncate(s.keys.get(key), 40)}`)
.join('; ');
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `Settings key conflict: "${key}"`,
description: `Key "${key}" has different values across scopes. ${details}`,
file: scopesWithKey[0].absPath,
evidence: details,
recommendation: `Verify the "${key}" value is intentionally different across scopes. The most specific scope wins (local > project > user).`,
}));
}
}
}
// --- Permission conflicts ---
for (let i = 0; i < settingsByScope.length; i++) {
for (let j = i + 1; j < settingsByScope.length; j++) {
const a = settingsByScope[i];
const b = settingsByScope[j];
const aPerms = a.raw.permissions || {};
const bPerms = b.raw.permissions || {};
const aAllow = Array.isArray(aPerms.allow) ? aPerms.allow : [];
const aDeny = Array.isArray(aPerms.deny) ? aPerms.deny : [];
const bAllow = Array.isArray(bPerms.allow) ? bPerms.allow : [];
const bDeny = Array.isArray(bPerms.deny) ? bPerms.deny : [];
// Check: allow in A, deny in B (and vice versa)
for (const allowRule of aAllow) {
for (const denyRule of bDeny) {
if (rulesIntersect(allowRule, denyRule)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Permission allow/deny conflict',
description: `"${allowRule}" is allowed in ${a.scope} (${a.file}) but denied in ${b.scope} (${b.file}).`,
file: a.absPath,
evidence: `allow: "${allowRule}" (${a.scope}) vs deny: "${denyRule}" (${b.scope})`,
recommendation: 'Resolve the conflict. Deny always wins, but the conflicting allow rule is misleading.',
}));
}
}
}
// Reverse: allow in B, deny in A
for (const allowRule of bAllow) {
for (const denyRule of aDeny) {
if (rulesIntersect(allowRule, denyRule)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Permission allow/deny conflict',
description: `"${allowRule}" is allowed in ${b.scope} (${b.file}) but denied in ${a.scope} (${a.file}).`,
file: b.absPath,
evidence: `allow: "${allowRule}" (${b.scope}) vs deny: "${denyRule}" (${a.scope})`,
recommendation: 'Resolve the conflict. Deny always wins, but the conflicting allow rule is misleading.',
}));
}
}
}
}
}
// --- Hook duplicates (across settings + hooks.json files) ---
const hookSources = []; // [{ event, matcher, source }]
for (const s of settingsByScope) {
if (s.raw.hooks) {
for (const h of collectHooks(s.raw)) {
hookSources.push({ ...h, source: `${s.scope}:${s.file}` });
}
}
}
for (const file of hooksFiles) {
const content = await readTextFile(file.absPath);
if (!content) continue;
const parsed = parseJson(content);
if (!parsed) continue;
const hookData = parsed.hooks || parsed;
for (const h of collectHooks(hookData)) {
hookSources.push({ ...h, source: `hooks:${file.relPath}` });
}
}
// Group by event:matcher
const hookGroups = new Map();
for (const h of hookSources) {
const key = `${h.event}:${h.matcher}`;
if (!hookGroups.has(key)) hookGroups.set(key, []);
hookGroups.get(key).push(h.source);
}
for (const [key, sources] of hookGroups) {
// Only flag duplicates from DIFFERENT sources
const uniqueSources = [...new Set(sources)];
if (uniqueSources.length >= 2) {
const [event, matcher] = key.split(':');
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Duplicate hook definition',
description: `Hook "${event}" with matcher "${matcher}" is defined in ${uniqueSources.length} sources.`,
evidence: uniqueSources.join(', '),
recommendation: 'Consolidate hook definitions to avoid unexpected execution order.',
}));
}
}
return scannerResult(SCANNER, 'ok', findings, totalFiles, Date.now() - start);
}