The DIS scanner collapsed Tool(param) rules to the bare tool name, so Agent(model:opus) deny + Agent(model:sonnet) allow (and the same for WebFetch(domain:...)) were flagged as dead config — a false positive now that CC 2.1.178 matches Tool(param:value) and 2.1.172 adds domain rules. The conflict-detector shared the blind spot from the other side: a wildcard deny like WebFetch(domain:*) did not cover a WebFetch(domain:good.com) allow, so a genuine cross-scope conflict was missed (false negative). New shared scanners/lib/permission-rules.mjs: - parseRule / paramMatches (glob) - dominates(deny, allow) -> DIS dead-allow (deny fully covers allow) - rulesIntersect(a, b) -> CNF cross-scope conflict (match sets intersect) DIS now delegates to dominates; conflict-detector :156 delegates to rulesIntersect. A bare deny still covers all params, so true positives are preserved (Bash deny + Bash(npm:*) allow still flagged). Re-seeded the marketplace-medium snapshots: the false-positive CA-DIS finding (Read(src/**) allow + Read(./.env) deny) is correctly gone. This changes snapshot CONTENT only — envelope schema is unchanged, so --json and --raw stay byte-stable. Full suite: 837/837 green (+25). self-audit PASS, A(100)/A(97).
98 lines
3.6 KiB
JavaScript
98 lines
3.6 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 } 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;
|
|
}
|
|
|
|
/**
|
|
* 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) 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',
|
|
}));
|
|
}
|
|
|
|
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
|
}
|