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
This commit is contained in:
Kjell Tore Guttormsen 2026-06-19 06:31:18 +02:00
commit 03949c6c98
9 changed files with 198 additions and 28 deletions

View file

@ -22,7 +22,7 @@ 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 } from './lib/permission-rules.mjs';
import { dominates, parseRule, isIneffectiveAllowGlob } from './lib/permission-rules.mjs';
const SCANNER = 'DIS';
@ -52,6 +52,19 @@ function findDenyAllowOverlaps(settings) {
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.
*
@ -70,28 +83,50 @@ export async function scan(targetPath, discovery) {
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',
}));
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);

View file

@ -55,8 +55,12 @@ export function paramMatches(pattern, value) {
/**
* Does the deny entry fully cover the allow entry, making the allow dead config?
* Used by DIS. Bare deny covers everything; a specific deny only covers the
* matching (or wildcard-subsumed) param and does NOT cover a bare allow.
* Used by DIS. Bare deny and the equivalent `Tool(*)` deny-all glob covers
* everything (including a bare allow); a specific deny only covers the matching
* (or wildcard-subsumed) param and does NOT kill a bare allow.
*
* CC: "`Bash(*)` is equivalent to `Bash` ... As a deny rule, both forms remove
* the tool from Claude's context." (code.claude.com/docs/en/permissions)
* @param {string} denyEntry
* @param {string} allowEntry
* @returns {boolean}
@ -65,7 +69,7 @@ export function dominates(denyEntry, allowEntry) {
const d = parseRule(denyEntry);
const a = parseRule(allowEntry);
if (!d.tool || !a.tool || d.tool !== a.tool) return false;
if (d.param === null) return true; // bare deny covers all params
if (d.param === null || d.param === '*') return true; // bare / Tool(*) deny covers all params
if (a.param === null) return false; // specific deny does not kill a bare allow
if (d.param === a.param) return true;
return paramMatches(d.param, a.param); // wildcard deny covers a matching literal allow
@ -87,3 +91,35 @@ export function rulesIntersect(ruleA, ruleB) {
if (a.param === b.param) return true;
return paramMatches(a.param, b.param) || paramMatches(b.param, a.param);
}
/**
* Is this `permissions.allow` entry an UNANCHORED tool-name glob that Claude
* Code silently skips? CC accepts tool-name globs in an ALLOW rule only after a
* literal `mcp__<server>__` prefix (the server segment must be glob-free).
* Unanchored globs like `*`, `B*`, or `mcp__*` are skipped with a warning and
* auto-approve nothing dead config the author believes is granting access.
*
* Tool-name globs apply to the bare-name form only (no `(...)` specifier); a
* glob INSIDE a specifier such as `Bash(npm run *)` is normal and valid.
*
* CC: "An unanchored allow glob such as `"*"`, `"B*"`, or `"mcp__*"` is skipped
* with a warning and does not auto-approve anything."
* (code.claude.com/docs/en/permissions "Tool name wildcards")
*
* NOTE: deny/ask rules DO accept tool-name globs, so this predicate is for the
* allow list only.
* @param {string} entry
* @returns {boolean}
*/
export function isIneffectiveAllowGlob(entry) {
if (typeof entry !== 'string') return false;
if (entry.includes('(')) return false; // specifier form — glob lives inside the param
if (!entry.includes('*')) return false; // no glob — a normal match-all-tool allow
if (entry.startsWith('mcp__')) {
const rest = entry.slice(5); // after 'mcp__'
const sep = rest.indexOf('__');
// mcp__<server>__<rest> with a glob-free server segment is anchored & valid
if (sep > 0 && !rest.slice(0, sep).includes('*')) return false;
}
return true;
}