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:
parent
dfe9049b55
commit
03949c6c98
9 changed files with 198 additions and 28 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue