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.
240 lines
8.6 KiB
JavaScript
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);
|
|
}
|