/** * 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 { 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'; const SCANNER = 'CNF'; // Keys checked separately or not meaningful to compare const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']); /** * Extract the tool name prefix from a permission rule. * e.g., "Bash(npm run *)" → "Bash", "Read(src/**)" → "Read" * @param {string} rule * @returns {{ tool: string, pattern: string }} */ function parsePermissionRule(rule) { const match = rule.match(/^(\w+)\((.+)\)$/); if (match) return { tool: match[1], pattern: match[2] }; return { tool: rule, pattern: '*' }; } /** * 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} 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} */ export async function scan(targetPath, discovery) { const start = Date.now(); const findings = []; // Collect settings files const settingsFiles = discovery.files.filter(f => f.type === 'settings-json'); // Collect hooks files const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json'); 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 }] 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) { const { tool: aTool, pattern: aPattern } = parsePermissionRule(allowRule); for (const denyRule of bDeny) { const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule); if (aTool === dTool && (aPattern === dPattern || aPattern === '*' || dPattern === '*')) { 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) { const { tool: bTool, pattern: bPattern } = parsePermissionRule(allowRule); for (const denyRule of aDeny) { const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule); if (bTool === dTool && (bPattern === dPattern || bPattern === '*' || dPattern === '*')) { 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); }