Extends the DIS scanner and its shared permission-rules lib with a third
documented Claude Code permission footgun. Verified verbatim against
code.claude.com/docs/en/permissions (fetched 2026-06-19).
CC's Tool(param:value) matching (2.1.178) is off-limits for a tool's own
canonicalizing field — CC ignores such a rule and emits a startup warning,
because e.g. Bash(command:rm *) is bypassable by a compound command. The
forbidden fields: command (Bash/PowerShell), file_path (Read/Edit/Write),
path (Grep/Glob), notebook_path (NotebookEdit), url (WebFetch).
- lib/permission-rules.mjs: new forbiddenParamRule(entry) returning
{ tool, key, hint } or null. Only the param:value form (colon present)
whose key equals the tool's forbidden field is flagged; Bash(npm:*),
WebFetch(domain:host), Agent(model:opus), and Bash(command) (no colon)
are left valid. FORBIDDEN_PARAMS map is the single source of truth.
- DIS: scans allow + deny + ask and splits severity by intent — deny/ask
hits are false security (medium: the block never applies), allow hits are
dead config (low: param:value matching is deny/ask-only). Two findings,
permissions-hygiene, CA-DIS-NNN.
- 11 new tests (7 lib, 4 DIS) + 1 fixture forbidden-param-permissions
(force-added past .gitignore .claude/). Suite 918 -> 929. Snapshot
unchanged (SC-5 byte-equal), contamination grep clean, gitleaks clean.
README/CLAUDE document the broadened DIS mandate; test badge synced.
self-audit: PASS, configGrade A 97, pluginGrade A 100, scanners 13.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
206 lines
8.5 KiB
JavaScript
206 lines
8.5 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, 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__<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',
|
|
}));
|
|
}
|
|
|
|
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);
|
|
}
|