/** * Permission rule matching — shared by the DIS scanner (dead-allow detection) * and the CNF conflict-detector (cross-scope allow/deny conflicts). * * Claude Code permission rules are either bare (`Tool`) or param-qualified * (`Tool(param)`): `Bash`, `Bash(npm:*)`, `Agent(model:opus)`, * `WebFetch(domain:*)`. CC 2.1.178 made `Tool(param:value)` matching * meaningful and 2.1.172 added `domain:` rules, so rule identity must be * param-aware — `Agent(model:opus)` and `Agent(model:sonnet)` are DISTINCT, * not "the same tool". * * Two distinct predicates are exported because the scanners ask different * questions: * - DIS asks "is this allow entry dead?" → does some deny fully COVER it * (`dominates`). A bare allow survives a specific deny. * - CNF asks "do these two cross-scope rules conflict?" → do their match * sets INTERSECT (`rulesIntersect`). A bare allow conflicts with a * specific deny, because the denied case is a real contradiction. * * Zero external dependencies. */ /** * Split a permission entry into its bare tool name and optional param. * `Bash` → { tool: 'Bash', param: null } * `Bash(npm:*)` → { tool: 'Bash', param: 'npm:*' } * @param {string} entry * @returns {{ tool: string|null, param: string|null }} */ export function parseRule(entry) { if (typeof entry !== 'string') return { tool: null, param: null }; const idx = entry.indexOf('('); if (idx === -1) return { tool: entry.trim() || null, param: null }; const tool = entry.slice(0, idx).trim(); let param = entry.slice(idx + 1).trim(); if (param.endsWith(')')) param = param.slice(0, -1).trim(); return { tool: tool || null, param }; } /** * Glob match a permission param against a concrete value. `*` matches any run * of characters; everything else is literal. Anchored (full-string) match. * @param {string} pattern * @param {string} value * @returns {boolean} */ export function paramMatches(pattern, value) { if (typeof pattern !== 'string' || typeof value !== 'string') return false; if (pattern === value) return true; const rx = '^' + pattern .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') .replace(/\\\*/g, '.*') + '$'; return new RegExp(rx).test(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. * @param {string} denyEntry * @param {string} allowEntry * @returns {boolean} */ 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 (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 } /** * Do two permission rules' match sets intersect (genuine cross-scope conflict)? * Used by CNF. A bare rule intersects any same-tool rule; param rules intersect * when equal or when either wildcard-matches the other. * @param {string} ruleA * @param {string} ruleB * @returns {boolean} */ export function rulesIntersect(ruleA, ruleB) { const a = parseRule(ruleA); const b = parseRule(ruleB); if (!a.tool || !b.tool || a.tool !== b.tool) return false; if (a.param === null || b.param === null) return true; // bare ∩ anything (same tool) if (a.param === b.param) return true; return paramMatches(a.param, b.param) || paramMatches(b.param, a.param); }