/** * 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 rule identity param-aware (CC 2.1.178 `Tool(param:value)`, * 2.1.172 `domain:` rules). An allow entry is dead only when some deny entry * fully COVERS it: a bare `Bash` deny blocks all `Bash(...)` allows, but * `Agent(model:opus)` deny does NOT kill an `Agent(model:sonnet)` allow. * Coverage logic lives in `lib/permission-rules.mjs` (shared with CNF). * * 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'; import { dominates, parseRule, isIneffectiveAllowGlob, forbiddenParamRule } from './lib/permission-rules.mjs'; const SCANNER = 'DIS'; /** * Find allow entries that are dead config because some deny entry fully covers * them. 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 overlaps = []; const seen = new Set(); for (const a of allowList) { if (typeof a !== 'string' || seen.has(a)) continue; const dominator = denyList.find(d => dominates(d, a)); if (dominator) { overlaps.push({ tool: parseRule(a).tool, allowEntry: a, denyEntry: dominator }); seen.add(a); } } return overlaps; } /** * Find `permissions.allow` entries that are unanchored tool-name globs Claude * Code silently skips (e.g. `mcp__*`, `B*`, `*`). They auto-approve nothing but * the author usually believes they grant access. Returns array of entry strings. */ function findIneffectiveAllowGlobs(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 : []; return allowList.filter(e => isIneffectiveAllowGlob(e)); } /** * Find permission rules CC silently ignores because their `Tool(param:value)` * key is the tool's own canonicalizing field (`command`, `file_path`, `path`, * `notebook_path`, `url`). Scans allow + deny + ask so severity can split: * deny/ask hits are false security, allow hits are dead config. Returns array * of { list, entry, tool, key, hint }. */ function findForbiddenParamRules(settings) { if (!settings || typeof settings !== 'object') return []; const perms = settings.permissions; if (!perms || typeof perms !== 'object') return []; const results = []; for (const list of ['allow', 'deny', 'ask']) { const arr = Array.isArray(perms[list]) ? perms[list] : []; for (const entry of arr) { const hit = forbiddenParamRule(entry); if (hit) results.push({ list, entry, ...hit }); } } return results; } /** * 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) { 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', })); } const ineffective = findIneffectiveAllowGlobs(parsed); if (ineffective.length > 0) { const evidence = `allow: ${ineffective.slice(0, 5).map(e => `"${e}"`).join(', ')}`; findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'Ineffective allow wildcard — Claude Code ignores this rule', description: `${f.relPath || f.absPath} has ${ineffective.length} permissions.allow ` + `entr${ineffective.length === 1 ? 'y' : 'ies'} that Claude Code skips: an ` + 'unanchored tool-name wildcard auto-approves nothing. CC accepts allow ' + 'wildcards only after a literal `mcp____` prefix.', file: f.absPath, evidence, recommendation: 'Replace `*`/`mcp__*` with explicit tool names, or anchor MCP wildcards to ' + 'a server (`mcp____*`). As written these entries grant nothing.', category: 'permissions-hygiene', })); } const forbidden = findForbiddenParamRules(parsed); const falseSecurity = forbidden.filter(x => x.list === 'deny' || x.list === 'ask'); const deadAllow = forbidden.filter(x => x.list === 'allow'); if (falseSecurity.length > 0) { const evidence = falseSecurity.slice(0, 5) .map(x => `${x.list}: "${x.entry}" → use ${x.hint}`) .join('; '); findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Permission rule silently ignored — deny/ask uses a forbidden param key', description: `${f.relPath || f.absPath} has ${falseSecurity.length} deny/ask ` + `rule${falseSecurity.length === 1 ? '' : 's'} whose \`Tool(param:value)\` key is ` + 'the tool\'s own canonicalizing field (`command`/`file_path`/`path`/`notebook_path`/' + '`url`). Claude Code ignores these and emits a startup warning, so the guard you ' + 'intended does NOT apply — the action you meant to block or gate is effectively ' + 'unrestricted.', file: f.absPath, evidence, recommendation: 'Rewrite each rule with the tool\'s own specifier syntax (e.g. `Bash(rm *)`, ' + '`Read(./path)`, `WebFetch(domain:host)`). As written these rules block nothing.', category: 'permissions-hygiene', })); } if (deadAllow.length > 0) { const evidence = deadAllow.slice(0, 5) .map(x => `allow: "${x.entry}" → use ${x.hint}`) .join('; '); findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'Permission rule silently ignored — allow uses a forbidden param key (dead config)', description: `${f.relPath || f.absPath} has ${deadAllow.length} permissions.allow ` + `rule${deadAllow.length === 1 ? '' : 's'} using \`Tool(param:value)\` on the tool's ` + 'own canonicalizing field. `param:value` matching applies only to deny/ask rules; ' + 'allow rules use each tool\'s own specifier syntax. Claude Code ignores these and ' + 'emits a startup warning — they grant nothing.', file: f.absPath, evidence, recommendation: 'Replace with the tool\'s specifier syntax (e.g. `Read(./path)`), or remove the ' + 'entry. As written it auto-approves nothing.', category: 'permissions-hygiene', })); } } return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start); }