config-audit/scanners/disabled-in-schema-scanner.mjs
Kjell Tore Guttormsen 03949c6c98 feat(dis): flag ineffective allow wildcards; treat Tool(*) as deny-all
Extends the DIS scanner and its shared permission-rules lib with two
documented Claude Code permission footguns. Verified verbatim against
code.claude.com/docs/en/permissions (fetched 2026-06-19).

- lib/permission-rules.mjs: new isIneffectiveAllowGlob(entry) — unanchored
  tool-name globs in permissions.allow (`*`, `B*`, `mcp__*`) that CC silently
  skips ("does not auto-approve anything"); valid only as a glob-free
  `mcp__<server>__*`. Shared with CNF.
- lib/permission-rules.mjs: dominates() now treats the `Tool(*)` deny-all glob
  as equivalent to a bare deny (covers a bare allow) — CC: "Bash(*) is
  equivalent to Bash ... both forms remove the tool from Claude's context".
- DIS: new finding "Ineffective allow wildcard — Claude Code ignores this rule"
  (low, permissions-hygiene, CA-DIS-NNN); the existing dead-allow finding now
  also catches a bare allow killed by a Tool(*) deny.
- 9 new tests (5 lib, 4 DIS) + 2 fixtures (force-added past .gitignore .claude/).
  Suite 903 -> 912. Snapshot unchanged, contamination grep clean. README/CLAUDE/
  scanner-internals document the broadened DIS mandate; test badge synced.
  self-audit: PASS, configGrade A 96, pluginGrade A 100, readme gate passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
2026-06-19 06:31:18 +02:00

133 lines
5.2 KiB
JavaScript

/**
* 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 } 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));
}
/**
* 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__<server>__` prefix.',
file: f.absPath,
evidence,
recommendation:
'Replace `*`/`mcp__*` with explicit tool names, or anchor MCP wildcards to ' +
'a server (`mcp__<server>__*`). As written these entries grant nothing.',
category: 'permissions-hygiene',
}));
}
}
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}