diff --git a/scanners/conflict-detector.mjs b/scanners/conflict-detector.mjs index 53150ff..44038b2 100644 --- a/scanners/conflict-detector.mjs +++ b/scanners/conflict-detector.mjs @@ -10,24 +10,13 @@ import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { parseJson } from './lib/yaml-parser.mjs'; import { truncate } from './lib/string-utils.mjs'; +import { rulesIntersect } from './lib/permission-rules.mjs'; const SCANNER = 'CNF'; // Keys checked separately or not meaningful to compare const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']); -/** - * Extract the tool name prefix from a permission rule. - * e.g., "Bash(npm run *)" → "Bash", "Read(src/**)" → "Read" - * @param {string} rule - * @returns {{ tool: string, pattern: string }} - */ -function parsePermissionRule(rule) { - const match = rule.match(/^(\w+)\((.+)\)$/); - if (match) return { tool: match[1], pattern: match[2] }; - return { tool: rule, pattern: '*' }; -} - /** * Flatten an object's top-level keys into a simple key→value map. * Only first level — we compare top-level settings, not nested. @@ -150,10 +139,8 @@ export async function scan(targetPath, discovery) { // Check: allow in A, deny in B (and vice versa) for (const allowRule of aAllow) { - const { tool: aTool, pattern: aPattern } = parsePermissionRule(allowRule); for (const denyRule of bDeny) { - const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule); - if (aTool === dTool && (aPattern === dPattern || aPattern === '*' || dPattern === '*')) { + if (rulesIntersect(allowRule, denyRule)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, @@ -169,10 +156,8 @@ export async function scan(targetPath, discovery) { // Reverse: allow in B, deny in A for (const allowRule of bAllow) { - const { tool: bTool, pattern: bPattern } = parsePermissionRule(allowRule); for (const denyRule of aDeny) { - const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule); - if (bTool === dTool && (bPattern === dPattern || bPattern === '*' || dPattern === '*')) { + if (rulesIntersect(allowRule, denyRule)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, diff --git a/scanners/disabled-in-schema-scanner.mjs b/scanners/disabled-in-schema-scanner.mjs index 6c95627..0968493 100644 --- a/scanners/disabled-in-schema-scanner.mjs +++ b/scanners/disabled-in-schema-scanner.mjs @@ -7,9 +7,11 @@ * intent. Often arises from copy-paste edits where one list was updated and * the other was forgotten. * - * Compares tool identity by the bare tool name (everything before the first - * `(`). `Bash(npm:*)` and `Bash` are treated as the same tool for collision - * purposes — a deny on `Bash` blocks all `Bash(...)` allows. + * 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. * @@ -20,21 +22,13 @@ 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'; /** - * Bare tool name = everything before the first `(`. `Bash(npm:*)` → `Bash`. - */ -function bareTool(entry) { - if (typeof entry !== 'string') return null; - const idx = entry.indexOf('('); - return (idx === -1 ? entry : entry.slice(0, idx)).trim(); -} - -/** - * Find tools whose bare name appears in both deny and allow within the same - * settings.json. Returns array of { tool, allowEntry, denyEntry }. + * 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 []; @@ -45,20 +39,14 @@ function findDenyAllowOverlaps(settings) { const denyList = Array.isArray(perms.deny) ? perms.deny : []; if (allowList.length === 0 || denyList.length === 0) return []; - const denyByBare = new Map(); - for (const d of denyList) { - const bare = bareTool(d); - if (bare && !denyByBare.has(bare)) denyByBare.set(bare, d); - } - const overlaps = []; const seen = new Set(); for (const a of allowList) { - const bare = bareTool(a); - if (!bare) continue; - if (denyByBare.has(bare) && !seen.has(bare)) { - overlaps.push({ tool: bare, allowEntry: a, denyEntry: denyByBare.get(bare) }); - seen.add(bare); + 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; diff --git a/scanners/lib/permission-rules.mjs b/scanners/lib/permission-rules.mjs new file mode 100644 index 0000000..773af69 --- /dev/null +++ b/scanners/lib/permission-rules.mjs @@ -0,0 +1,89 @@ +/** + * 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); +} diff --git a/tests/fixtures/param-conflict-project/.claude/settings.json b/tests/fixtures/param-conflict-project/.claude/settings.json new file mode 100644 index 0000000..d5dd2f8 --- /dev/null +++ b/tests/fixtures/param-conflict-project/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "permissions": { + "allow": ["WebFetch(domain:good.com)", "Agent(model:sonnet)"], + "deny": [] + } +} diff --git a/tests/fixtures/param-conflict-project/.claude/settings.local.json b/tests/fixtures/param-conflict-project/.claude/settings.local.json new file mode 100644 index 0000000..d9da00c --- /dev/null +++ b/tests/fixtures/param-conflict-project/.claude/settings.local.json @@ -0,0 +1,6 @@ +{ + "permissions": { + "allow": [], + "deny": ["WebFetch(domain:*)", "Agent(model:opus)"] + } +} diff --git a/tests/fixtures/param-qualified-permissions/.claude/settings.json b/tests/fixtures/param-qualified-permissions/.claude/settings.json new file mode 100644 index 0000000..4b83cc2 --- /dev/null +++ b/tests/fixtures/param-qualified-permissions/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "permissions": { + "allow": ["Agent(model:sonnet)", "WebFetch(domain:good.com)", "Bash(npm:*)"], + "deny": ["Agent(model:opus)", "WebFetch(domain:evil.com)", "Bash"] + } +} diff --git a/tests/lib/permission-rules.test.mjs b/tests/lib/permission-rules.test.mjs new file mode 100644 index 0000000..aa139b8 --- /dev/null +++ b/tests/lib/permission-rules.test.mjs @@ -0,0 +1,100 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseRule, + paramMatches, + dominates, + rulesIntersect, +} from '../../scanners/lib/permission-rules.mjs'; + +describe('permission-rules — parseRule', () => { + it('bare tool → param null', () => { + assert.deepEqual(parseRule('Bash'), { tool: 'Bash', param: null }); + }); + + it('param-qualified tool → tool + param', () => { + assert.deepEqual(parseRule('Bash(npm:*)'), { tool: 'Bash', param: 'npm:*' }); + }); + + it('domain rule → tool + param', () => { + assert.deepEqual(parseRule('WebFetch(domain:example.com)'), { + tool: 'WebFetch', + param: 'domain:example.com', + }); + }); + + it('non-string → tool null', () => { + assert.equal(parseRule(null).tool, null); + }); +}); + +describe('permission-rules — paramMatches (glob)', () => { + it('wildcard matches any suffix', () => { + assert.equal(paramMatches('domain:*', 'domain:good.com'), true); + assert.equal(paramMatches('npm:*', 'npm:install'), true); + }); + + it('exact literal matches', () => { + assert.equal(paramMatches('domain:good.com', 'domain:good.com'), true); + }); + + it('distinct literals do not match', () => { + assert.equal(paramMatches('domain:evil.com', 'domain:good.com'), false); + assert.equal(paramMatches('model:opus', 'model:sonnet'), false); + }); + + it('literal pattern does not match a wildcard value', () => { + assert.equal(paramMatches('domain:good.com', 'domain:*'), false); + }); +}); + +describe('permission-rules — dominates (deny fully covers allow → dead allow)', () => { + it('bare deny covers any param-qualified allow', () => { + assert.equal(dominates('Bash', 'Bash(npm:*)'), true); + }); + + it('exact param deny covers identical allow', () => { + assert.equal(dominates('Agent(model:opus)', 'Agent(model:opus)'), true); + }); + + it('wildcard deny covers matching literal allow', () => { + assert.equal(dominates('WebFetch(domain:*)', 'WebFetch(domain:example.com)'), true); + }); + + it('distinct param deny does NOT cover a different param allow (the false positive)', () => { + assert.equal(dominates('Agent(model:opus)', 'Agent(model:sonnet)'), false); + assert.equal(dominates('WebFetch(domain:evil.com)', 'WebFetch(domain:good.com)'), false); + }); + + it('specific deny does NOT cover a bare allow (sonnet still allowed)', () => { + assert.equal(dominates('Agent(model:opus)', 'Agent'), false); + }); + + it('different tools never dominate', () => { + assert.equal(dominates('Bash', 'Read'), false); + }); +}); + +describe('permission-rules — rulesIntersect (cross-scope conflict)', () => { + it('exact same rule intersects', () => { + assert.equal(rulesIntersect('Bash(npm run *)', 'Bash(npm run *)'), true); + }); + + it('bare tool intersects any param of same tool', () => { + assert.equal(rulesIntersect('Bash', 'Bash(npm:*)'), true); + assert.equal(rulesIntersect('Agent(model:opus)', 'Agent'), true); + }); + + it('wildcard intersects matching literal (currently a false negative)', () => { + assert.equal(rulesIntersect('WebFetch(domain:*)', 'WebFetch(domain:good.com)'), true); + assert.equal(rulesIntersect('WebFetch(domain:good.com)', 'WebFetch(domain:*)'), true); + }); + + it('distinct literal params are disjoint (no conflict)', () => { + assert.equal(rulesIntersect('Agent(model:opus)', 'Agent(model:sonnet)'), false); + }); + + it('different tools never intersect', () => { + assert.equal(rulesIntersect('Read', 'Write'), false); + }); +}); diff --git a/tests/scanners/conflict-detector.test.mjs b/tests/scanners/conflict-detector.test.mjs index cfca3be..7ab59d5 100644 --- a/tests/scanners/conflict-detector.test.mjs +++ b/tests/scanners/conflict-detector.test.mjs @@ -122,3 +122,37 @@ describe('CNF scanner — minimal project', () => { assert.equal(result.findings.length, 0); }); }); + +describe('CNF scanner — param-qualified cross-scope conflicts', () => { + // project allow: WebFetch(domain:good.com), Agent(model:sonnet) + // local deny: WebFetch(domain:*), Agent(model:opus) + // WebFetch: deny domain:* covers allow domain:good.com → genuine conflict. + // Agent: deny model:opus vs allow model:sonnet → disjoint → NO conflict. + let result; + beforeEach(async () => { + resetCounter(); + const discovery = await discoverConfigFiles(resolve(FIXTURES, 'param-conflict-project')); + result = await scan(resolve(FIXTURES, 'param-conflict-project'), discovery); + }); + + it('detects the WebFetch wildcard-domain conflict (currently a false negative)', () => { + const perm = result.findings.filter(f => f.title.includes('Permission allow/deny')); + assert.ok( + perm.some(f => /WebFetch/.test(`${f.description} ${f.evidence}`)), + `expected a WebFetch allow/deny conflict; got: ${perm.map(f => f.evidence).join(' | ') || '(none)'}`, + ); + }); + + it('does NOT flag Agent(model:sonnet) vs Agent(model:opus) as a conflict', () => { + const perm = result.findings.filter(f => f.title.includes('Permission allow/deny')); + assert.ok( + !perm.some(f => /Agent/.test(`${f.description} ${f.evidence}`)), + 'distinct model params must not conflict', + ); + }); + + it('reports exactly one permission conflict', () => { + const perm = result.findings.filter(f => f.title.includes('Permission allow/deny')); + assert.equal(perm.length, 1); + }); +}); diff --git a/tests/scanners/disabled-in-schema.test.mjs b/tests/scanners/disabled-in-schema.test.mjs index f4be4f3..807e38a 100644 --- a/tests/scanners/disabled-in-schema.test.mjs +++ b/tests/scanners/disabled-in-schema.test.mjs @@ -66,3 +66,32 @@ describe('DIS scanner — orchestrator wiring', () => { assert.ok(dis, `expected DIS in orchestrator results; got: ${env.scanners.map(r => r.scanner).join(', ')}`); }); }); + +describe('DIS scanner — param-qualified permissions are param-aware', () => { + // settings.json: + // allow: Agent(model:sonnet), WebFetch(domain:good.com), Bash(npm:*) + // deny: Agent(model:opus), WebFetch(domain:evil.com), Bash + // Only the bare `Bash` deny dominates an allow (Bash(npm:*)). The param- + // qualified deny/allow pairs are DISTINCT specs (CC 2.1.178 param-matching, + // 2.1.172 domain rules) and must NOT be flagged as dead config. + + it('flags exactly the Bash overlap (bare deny covers param allow)', async () => { + const result = await runScanner('param-qualified-permissions'); + const f = result.findings.find(x => /both permissions/i.test(x.title || '')); + assert.ok(f, 'expected the Bash overlap to still be flagged'); + assert.match(String(f.evidence || ''), /Bash/); + assert.match(String(f.description || ''), /contains 1 tool\b/); + }); + + it('does NOT flag Agent(model:opus)/Agent(model:sonnet) as dead config', async () => { + const result = await runScanner('param-qualified-permissions'); + const f = result.findings.find(x => /both permissions/i.test(x.title || '')); + assert.doesNotMatch(String(f?.evidence || ''), /Agent/); + }); + + it('does NOT flag WebFetch(domain:good.com)/WebFetch(domain:evil.com) as dead config', async () => { + const result = await runScanner('param-qualified-permissions'); + const f = result.findings.find(x => /both permissions/i.test(x.title || '')); + assert.doesNotMatch(String(f?.evidence || ''), /WebFetch/); + }); +}); diff --git a/tests/snapshots/default-output/posture.json b/tests/snapshots/default-output/posture.json index b34af99..8f1e118 100644 --- a/tests/snapshots/default-output/posture.json +++ b/tests/snapshots/default-output/posture.json @@ -1,4 +1,4 @@ { "kind": "text", - "payload": "`[CML] CLAUDE.md Linter`: 1 finding(s) (0ms)\n `[SET] Settings Validator`: 0 finding(s) (0ms)\n `[HKV] Hook Validator`: 0 finding(s) (0ms)\n `[RUL] Rules Validator`: 0 finding(s) (0ms)\n `[MCP] MCP Config Validator`: 0 finding(s) (0ms)\n `[IMP] Import Resolver`: 0 finding(s) (0ms)\n `[CNF] Conflict Detector`: 0 finding(s) (0ms)\n `[GAP] Feature Gap Scanner`: 17 finding(s) (0ms)\n `[TOK] Token Hotspots`: 1 finding(s) (0ms)\n `[CPS] Cache-Prefix Stability`: 0 finding(s) (0ms)\n `[DIS] Disabled-In-Schema`: 1 finding(s) (0ms)\n `[COL] Plugin Skill Collision`: 0 finding(s) (0ms)\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n Configuration health\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n Health: A (97/100) — Healthy setup, only minor polish needed\n 9 areas reviewed\n\n Area scores\n ───────────\n `CLAUDE.md` ........... A (90) `Settings` ............ A (90)\n `Hooks` ............... A (100) `Rules` ............... A (100)\n `MCP` ................. A (100) `Imports` ............. A (100)\n `Conflicts` ........... A (100) `Token Efficiency` .... A (90)\n `Plugin Hygiene` ...... A (100)\n\n 17 ways you could get more out of Claude Code — see /config-audit feature-gap\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + "payload": "`[CML] CLAUDE.md Linter`: 1 finding(s) (0ms)\n `[SET] Settings Validator`: 0 finding(s) (0ms)\n `[HKV] Hook Validator`: 0 finding(s) (0ms)\n `[RUL] Rules Validator`: 0 finding(s) (0ms)\n `[MCP] MCP Config Validator`: 0 finding(s) (0ms)\n `[IMP] Import Resolver`: 0 finding(s) (0ms)\n `[CNF] Conflict Detector`: 0 finding(s) (0ms)\n `[GAP] Feature Gap Scanner`: 17 finding(s) (0ms)\n `[TOK] Token Hotspots`: 1 finding(s) (0ms)\n `[CPS] Cache-Prefix Stability`: 0 finding(s) (0ms)\n `[DIS] Disabled-In-Schema`: 0 finding(s) (0ms)\n `[COL] Plugin Skill Collision`: 0 finding(s) (0ms)\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n Configuration health\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n Health: A (98/100) — Healthy setup, only minor polish needed\n 9 areas reviewed\n\n Area scores\n ───────────\n `CLAUDE.md` ........... A (90) `Settings` ............ A (100)\n `Hooks` ............... A (100) `Rules` ............... A (100)\n `MCP` ................. A (100) `Imports` ............. A (100)\n `Conflicts` ........... A (100) `Token Efficiency` .... A (90)\n `Plugin Hygiene` ...... A (100)\n\n 17 ways you could get more out of Claude Code — see /config-audit feature-gap\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" } diff --git a/tests/snapshots/default-output/scan-orchestrator.json b/tests/snapshots/default-output/scan-orchestrator.json index 8474bd6..d3c67b7 100644 --- a/tests/snapshots/default-output/scan-orchestrator.json +++ b/tests/snapshots/default-output/scan-orchestrator.json @@ -517,29 +517,12 @@ "status": "ok", "files_scanned": 1, "duration_ms": 0, - "findings": [ - { - "id": "CA-DIS-001", - "scanner": "DIS", - "severity": "low", - "title": "A tool is in both the let-in list and the shut-out list", - "description": "When a tool is in both lists, the shut-out always wins, so the let-in entry does nothing. It looks like the tool is approved, but it isn't.", - "file": "/Users/ktg/repos/ktg-plugin-marketplace/config-audit/tests/fixtures/marketplace-medium/.claude/settings.json", - "line": null, - "evidence": "Read: allow=\"Read(src/**)\" + deny=\"Read(./.env)\"", - "category": "permissions-hygiene", - "recommendation": "Decide whether the tool should be allowed or denied, and remove it from the other list.", - "autoFixable": false, - "userImpactCategory": "Dead config", - "userActionLanguage": "Optional cleanup", - "relevanceContext": "test-fixture-no-impact" - } - ], + "findings": [], "counts": { "critical": 0, "high": 0, "medium": 0, - "low": 1, + "low": 0, "info": 0 } }, @@ -559,15 +542,15 @@ } ], "aggregate": { - "total_findings": 20, + "total_findings": 19, "counts": { "critical": 0, "high": 0, "medium": 1, - "low": 8, + "low": 7, "info": 11 }, - "risk_score": 12, + "risk_score": 11, "risk_band": "Medium", "verdict": "PASS", "scanners_ok": 11, diff --git a/tests/snapshots/v5.0.0-stderr/posture.txt b/tests/snapshots/v5.0.0-stderr/posture.txt index d605446..4814745 100644 --- a/tests/snapshots/v5.0.0-stderr/posture.txt +++ b/tests/snapshots/v5.0.0-stderr/posture.txt @@ -1,4 +1,4 @@ - [CML] CLAUDE.md Linter: 1 finding(s) (13ms) + [CML] CLAUDE.md Linter: 1 finding(s) (14ms) [SET] Settings Validator: 0 finding(s) (1ms) [HKV] Hook Validator: 0 finding(s) (1ms) [RUL] Rules Validator: 0 finding(s) (1ms) @@ -8,18 +8,18 @@ [GAP] Feature Gap Scanner: 17 finding(s) (2ms) [TOK] Token Hotspots: 1 finding(s) (8ms) [CPS] Cache-Prefix Stability: 0 finding(s) (1ms) - [DIS] Disabled-In-Schema: 1 finding(s) (1ms) - [COL] Plugin Skill Collision: 0 finding(s) (0ms) + [DIS] Disabled-In-Schema: 0 finding(s) (0ms) + [COL] Plugin Skill Collision: 0 finding(s) (1ms) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Config-Audit Health Score ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Health: A (97/100) 9 areas scanned + Health: A (98/100) 9 areas scanned Area Scores ─────────── - CLAUDE.md ........... A (90) Settings ............ A (90) + CLAUDE.md ........... A (90) Settings ............ A (100) Hooks ............... A (100) Rules ............... A (100) MCP ................. A (100) Imports ............. A (100) Conflicts ........... A (100) Token Efficiency .... A (90) diff --git a/tests/snapshots/v5.0.0/drift.json b/tests/snapshots/v5.0.0/drift.json index 9ff166a..07a63fb 100644 --- a/tests/snapshots/v5.0.0/drift.json +++ b/tests/snapshots/v5.0.0/drift.json @@ -248,29 +248,16 @@ "category": "token-efficiency", "recommendation": "Install the package locally (so detect-mcp-tool-count can read its manifest), or run the server once and cache its tools/list response under ~/.claude/config-audit/mcp-cache/.json. See knowledge/cache-telemetry-recipe.md.", "autoFixable": false - }, - { - "id": "CA-DIS-001", - "scanner": "DIS", - "severity": "low", - "title": "Tool listed in both permissions.deny and permissions.allow", - "description": ".claude/settings.json contains 1 tool 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": "/Users/ktg/repos/ktg-plugin-marketplace/config-audit/tests/fixtures/marketplace-medium/.claude/settings.json", - "line": null, - "evidence": "Read: allow=\"Read(src/**)\" + deny=\"Read(./.env)\"", - "category": "permissions-hygiene", - "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.", - "autoFixable": false } ], "movedFindings": [], "scoreChange": { "before": { - "score": 91, + "score": 92, "grade": "A" }, "after": { - "score": 91, + "score": 92, "grade": "A" }, "delta": 0 @@ -291,11 +278,11 @@ { "name": "Settings", "before": { - "score": 90, + "score": 100, "grade": "A" }, "after": { - "score": 90, + "score": 100, "grade": "A" }, "delta": 0 @@ -398,8 +385,8 @@ } ], "summary": { - "totalBefore": 20, - "totalAfter": 20, + "totalBefore": 19, + "totalAfter": 19, "newCount": 0, "resolvedCount": 0, "trend": "stable" diff --git a/tests/snapshots/v5.0.0/fix-cli.json b/tests/snapshots/v5.0.0/fix-cli.json index 5d45a67..b644b54 100644 --- a/tests/snapshots/v5.0.0/fix-cli.json +++ b/tests/snapshots/v5.0.0/fix-cli.json @@ -118,12 +118,6 @@ "title": "High MCP tool-schema budget on server \"memory\"", "file": ".mcp.json", "recommendation": "Install the package locally (so detect-mcp-tool-count can read its manifest), or run the server once and cache its tools/list response under ~/.claude/config-audit/mcp-cache/.json. See knowledge/cache-telemetry-recipe.md." - }, - { - "findingId": "CA-DIS-001", - "title": "Tool listed in both permissions.deny and permissions.allow", - "file": "/Users/ktg/repos/ktg-plugin-marketplace/config-audit/tests/fixtures/marketplace-medium/.claude/settings.json", - "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." } ], "backupId": null diff --git a/tests/snapshots/v5.0.0/posture.json b/tests/snapshots/v5.0.0/posture.json index 603da98..cf709c4 100644 --- a/tests/snapshots/v5.0.0/posture.json +++ b/tests/snapshots/v5.0.0/posture.json @@ -24,8 +24,8 @@ "id": "settings", "name": "Settings", "grade": "A", - "score": 90, - "findingCount": 1 + "score": 100, + "findingCount": 0 }, { "id": "hooks", @@ -94,7 +94,7 @@ "scannerEnvelope": { "meta": { "target": "/Users/ktg/repos/ktg-plugin-marketplace/config-audit/tests/fixtures/marketplace-medium", - "timestamp": "2026-06-18T10:18:35.634Z", + "timestamp": "2026-06-18T11:03:14.339Z", "version": "2.2.0", "tool": "config-audit" }, @@ -103,7 +103,7 @@ "scanner": "CML", "status": "ok", "files_scanned": 1, - "duration_ms": 3, + "duration_ms": 2, "findings": [ { "id": "CA-CML-001", @@ -187,7 +187,7 @@ "scanner": "IMP", "status": "ok", "files_scanned": 1, - "duration_ms": 2, + "duration_ms": 1, "findings": [], "counts": { "critical": 0, @@ -551,26 +551,12 @@ "status": "ok", "files_scanned": 1, "duration_ms": 0, - "findings": [ - { - "id": "CA-DIS-001", - "scanner": "DIS", - "severity": "low", - "title": "Tool listed in both permissions.deny and permissions.allow", - "description": ".claude/settings.json contains 1 tool 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": "/Users/ktg/repos/ktg-plugin-marketplace/config-audit/tests/fixtures/marketplace-medium/.claude/settings.json", - "line": null, - "evidence": "Read: allow=\"Read(src/**)\" + deny=\"Read(./.env)\"", - "category": "permissions-hygiene", - "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.", - "autoFixable": false - } - ], + "findings": [], "counts": { "critical": 0, "high": 0, "medium": 0, - "low": 1, + "low": 0, "info": 0 } }, @@ -578,7 +564,7 @@ "scanner": "COL", "status": "ok", "files_scanned": 0, - "duration_ms": 0, + "duration_ms": 1, "findings": [], "counts": { "critical": 0, @@ -590,15 +576,15 @@ } ], "aggregate": { - "total_findings": 20, + "total_findings": 19, "counts": { "critical": 0, "high": 0, "medium": 1, - "low": 8, + "low": 7, "info": 11 }, - "risk_score": 12, + "risk_score": 11, "risk_band": "Medium", "verdict": "PASS", "scanners_ok": 11, diff --git a/tests/snapshots/v5.0.0/scan-orchestrator.json b/tests/snapshots/v5.0.0/scan-orchestrator.json index af1d507..3b8f5eb 100644 --- a/tests/snapshots/v5.0.0/scan-orchestrator.json +++ b/tests/snapshots/v5.0.0/scan-orchestrator.json @@ -1,7 +1,7 @@ { "meta": { "target": "/Users/ktg/repos/ktg-plugin-marketplace/config-audit/tests/fixtures/marketplace-medium", - "timestamp": "2026-06-18T10:18:35.439Z", + "timestamp": "2026-06-18T11:03:14.159Z", "version": "2.2.0", "tool": "config-audit" }, @@ -122,7 +122,7 @@ "scanner": "GAP", "status": "ok", "files_scanned": 4, - "duration_ms": 2, + "duration_ms": 1, "findings": [ { "id": "CA-GAP-001", @@ -358,7 +358,7 @@ "scanner": "TOK", "status": "ok", "files_scanned": 2, - "duration_ms": 8, + "duration_ms": 7, "findings": [ { "id": "CA-TOK-001", @@ -457,27 +457,13 @@ "scanner": "DIS", "status": "ok", "files_scanned": 1, - "duration_ms": 1, - "findings": [ - { - "id": "CA-DIS-001", - "scanner": "DIS", - "severity": "low", - "title": "Tool listed in both permissions.deny and permissions.allow", - "description": ".claude/settings.json contains 1 tool 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": "/Users/ktg/repos/ktg-plugin-marketplace/config-audit/tests/fixtures/marketplace-medium/.claude/settings.json", - "line": null, - "evidence": "Read: allow=\"Read(src/**)\" + deny=\"Read(./.env)\"", - "category": "permissions-hygiene", - "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.", - "autoFixable": false - } - ], + "duration_ms": 0, + "findings": [], "counts": { "critical": 0, "high": 0, "medium": 0, - "low": 1, + "low": 0, "info": 0 } }, @@ -485,7 +471,7 @@ "scanner": "COL", "status": "ok", "files_scanned": 0, - "duration_ms": 0, + "duration_ms": 1, "findings": [], "counts": { "critical": 0, @@ -497,15 +483,15 @@ } ], "aggregate": { - "total_findings": 20, + "total_findings": 19, "counts": { "critical": 0, "high": 0, "medium": 1, - "low": 8, + "low": 7, "info": 11 }, - "risk_score": 12, + "risk_score": 11, "risk_band": "Medium", "verdict": "PASS", "scanners_ok": 11,