/** * DIS Scanner — Disabled-Tools-Still-In-Schema Detector (v5 N4) * * Detects tools that appear in BOTH `permissions.deny` and `permissions.allow` * within the same settings.json file. The deny list wins, so the allow entry * is dead config — but it still loads on every turn and signals confused * intent. Often arises from copy-paste edits where one list was updated and * the other was forgotten. * * Compares tool identity by the bare tool name (everything before the first * `(`). `Bash(npm:*)` and `Bash` are treated as the same tool for collision * purposes — a deny on `Bash` blocks all `Bash(...)` allows. * * Finding ID: CA-DIS-NNN. Severity: low. * * Zero external dependencies. */ 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'; const SCANNER = 'DIS'; /** * Bare tool name = everything before the first `(`. `Bash(npm:*)` → `Bash`. */ function bareTool(entry) { if (typeof entry !== 'string') return null; const idx = entry.indexOf('('); return (idx === -1 ? entry : entry.slice(0, idx)).trim(); } /** * Find tools whose bare name appears in both deny and allow within the same * settings.json. Returns array of { tool, allowEntry, denyEntry }. */ function findDenyAllowOverlaps(settings) { if (!settings || typeof settings !== 'object') return []; const perms = settings.permissions; if (!perms || typeof perms !== 'object') return []; const allowList = Array.isArray(perms.allow) ? perms.allow : []; const denyList = Array.isArray(perms.deny) ? perms.deny : []; if (allowList.length === 0 || denyList.length === 0) return []; const denyByBare = new Map(); for (const d of denyList) { const bare = bareTool(d); if (bare && !denyByBare.has(bare)) denyByBare.set(bare, d); } const overlaps = []; const seen = new Set(); for (const a of allowList) { const bare = bareTool(a); if (!bare) continue; if (denyByBare.has(bare) && !seen.has(bare)) { overlaps.push({ tool: bare, allowEntry: a, denyEntry: denyByBare.get(bare) }); seen.add(bare); } } return overlaps; } /** * Main scanner entry point. * * @param {string} targetPath * @param {{files: Array<{absPath:string, relPath:string, type:string}>}} discovery */ export async function scan(targetPath, discovery) { const start = Date.now(); const findings = []; let filesScanned = 0; for (const f of discovery.files) { if (f.type !== 'settings-json') continue; filesScanned++; const content = await readTextFile(f.absPath); if (!content) continue; const parsed = parseJson(content); if (!parsed) continue; const overlaps = findDenyAllowOverlaps(parsed); if (overlaps.length === 0) continue; const evidence = overlaps.slice(0, 5) .map(o => `${o.tool}: allow="${o.allowEntry}" + deny="${o.denyEntry}"`) .join('; '); findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'Tool listed in both permissions.deny and permissions.allow', description: `${f.relPath || f.absPath} contains ${overlaps.length} tool` + `${overlaps.length === 1 ? '' : 's'} present in both deny and allow lists. ` + 'The deny list wins — the allow entries are dead config but still load on ' + 'every turn and may confuse future readers about intent.', file: f.absPath, evidence, recommendation: 'Remove the redundant allow entries. If you actually want this tool enabled, ' + 'remove it from the deny list instead. Settings should express intent clearly.', category: 'permissions-hygiene', })); } return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start); }