fix(permissions): param-aware DIS dead-allow + CNF conflict matching
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).
This commit is contained in:
parent
8216fb4175
commit
bec3f45329
16 changed files with 324 additions and 145 deletions
|
|
@ -10,24 +10,13 @@ import { finding, scannerResult } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
import { parseJson } from './lib/yaml-parser.mjs';
|
import { parseJson } from './lib/yaml-parser.mjs';
|
||||||
import { truncate } from './lib/string-utils.mjs';
|
import { truncate } from './lib/string-utils.mjs';
|
||||||
|
import { rulesIntersect } from './lib/permission-rules.mjs';
|
||||||
|
|
||||||
const SCANNER = 'CNF';
|
const SCANNER = 'CNF';
|
||||||
|
|
||||||
// Keys checked separately or not meaningful to compare
|
// Keys checked separately or not meaningful to compare
|
||||||
const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']);
|
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.
|
* Flatten an object's top-level keys into a simple key→value map.
|
||||||
* Only first level — we compare top-level settings, not nested.
|
* 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)
|
// Check: allow in A, deny in B (and vice versa)
|
||||||
for (const allowRule of aAllow) {
|
for (const allowRule of aAllow) {
|
||||||
const { tool: aTool, pattern: aPattern } = parsePermissionRule(allowRule);
|
|
||||||
for (const denyRule of bDeny) {
|
for (const denyRule of bDeny) {
|
||||||
const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule);
|
if (rulesIntersect(allowRule, denyRule)) {
|
||||||
if (aTool === dTool && (aPattern === dPattern || aPattern === '*' || dPattern === '*')) {
|
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
scanner: SCANNER,
|
scanner: SCANNER,
|
||||||
severity: SEVERITY.high,
|
severity: SEVERITY.high,
|
||||||
|
|
@ -169,10 +156,8 @@ export async function scan(targetPath, discovery) {
|
||||||
|
|
||||||
// Reverse: allow in B, deny in A
|
// Reverse: allow in B, deny in A
|
||||||
for (const allowRule of bAllow) {
|
for (const allowRule of bAllow) {
|
||||||
const { tool: bTool, pattern: bPattern } = parsePermissionRule(allowRule);
|
|
||||||
for (const denyRule of aDeny) {
|
for (const denyRule of aDeny) {
|
||||||
const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule);
|
if (rulesIntersect(allowRule, denyRule)) {
|
||||||
if (bTool === dTool && (bPattern === dPattern || bPattern === '*' || dPattern === '*')) {
|
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
scanner: SCANNER,
|
scanner: SCANNER,
|
||||||
severity: SEVERITY.high,
|
severity: SEVERITY.high,
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@
|
||||||
* intent. Often arises from copy-paste edits where one list was updated and
|
* intent. Often arises from copy-paste edits where one list was updated and
|
||||||
* the other was forgotten.
|
* the other was forgotten.
|
||||||
*
|
*
|
||||||
* Compares tool identity by the bare tool name (everything before the first
|
* Compares rule identity param-aware (CC 2.1.178 `Tool(param:value)`,
|
||||||
* `(`). `Bash(npm:*)` and `Bash` are treated as the same tool for collision
|
* 2.1.172 `domain:` rules). An allow entry is dead only when some deny entry
|
||||||
* purposes — a deny on `Bash` blocks all `Bash(...)` allows.
|
* 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.
|
* 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 { finding, scannerResult } from './lib/output.mjs';
|
||||||
import { SEVERITY } from './lib/severity.mjs';
|
import { SEVERITY } from './lib/severity.mjs';
|
||||||
import { parseJson } from './lib/yaml-parser.mjs';
|
import { parseJson } from './lib/yaml-parser.mjs';
|
||||||
|
import { dominates, parseRule } from './lib/permission-rules.mjs';
|
||||||
|
|
||||||
const SCANNER = 'DIS';
|
const SCANNER = 'DIS';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bare tool name = everything before the first `(`. `Bash(npm:*)` → `Bash`.
|
* Find allow entries that are dead config because some deny entry fully covers
|
||||||
*/
|
* them. Returns array of { tool, allowEntry, denyEntry }.
|
||||||
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 }.
|
|
||||||
*/
|
*/
|
||||||
function findDenyAllowOverlaps(settings) {
|
function findDenyAllowOverlaps(settings) {
|
||||||
if (!settings || typeof settings !== 'object') return [];
|
if (!settings || typeof settings !== 'object') return [];
|
||||||
|
|
@ -45,20 +39,14 @@ function findDenyAllowOverlaps(settings) {
|
||||||
const denyList = Array.isArray(perms.deny) ? perms.deny : [];
|
const denyList = Array.isArray(perms.deny) ? perms.deny : [];
|
||||||
if (allowList.length === 0 || denyList.length === 0) return [];
|
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 overlaps = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const a of allowList) {
|
for (const a of allowList) {
|
||||||
const bare = bareTool(a);
|
if (typeof a !== 'string' || seen.has(a)) continue;
|
||||||
if (!bare) continue;
|
const dominator = denyList.find(d => dominates(d, a));
|
||||||
if (denyByBare.has(bare) && !seen.has(bare)) {
|
if (dominator) {
|
||||||
overlaps.push({ tool: bare, allowEntry: a, denyEntry: denyByBare.get(bare) });
|
overlaps.push({ tool: parseRule(a).tool, allowEntry: a, denyEntry: dominator });
|
||||||
seen.add(bare);
|
seen.add(a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return overlaps;
|
return overlaps;
|
||||||
|
|
|
||||||
89
scanners/lib/permission-rules.mjs
Normal file
89
scanners/lib/permission-rules.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
6
tests/fixtures/param-conflict-project/.claude/settings.json
vendored
Normal file
6
tests/fixtures/param-conflict-project/.claude/settings.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": ["WebFetch(domain:good.com)", "Agent(model:sonnet)"],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
||||||
6
tests/fixtures/param-conflict-project/.claude/settings.local.json
vendored
Normal file
6
tests/fixtures/param-conflict-project/.claude/settings.local.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [],
|
||||||
|
"deny": ["WebFetch(domain:*)", "Agent(model:opus)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
tests/fixtures/param-qualified-permissions/.claude/settings.json
vendored
Normal file
6
tests/fixtures/param-qualified-permissions/.claude/settings.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": ["Agent(model:sonnet)", "WebFetch(domain:good.com)", "Bash(npm:*)"],
|
||||||
|
"deny": ["Agent(model:opus)", "WebFetch(domain:evil.com)", "Bash"]
|
||||||
|
}
|
||||||
|
}
|
||||||
100
tests/lib/permission-rules.test.mjs
Normal file
100
tests/lib/permission-rules.test.mjs
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -122,3 +122,37 @@ describe('CNF scanner — minimal project', () => {
|
||||||
assert.equal(result.findings.length, 0);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(', ')}`);
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"kind": "text",
|
"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━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -517,29 +517,12 @@
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"files_scanned": 1,
|
"files_scanned": 1,
|
||||||
"duration_ms": 0,
|
"duration_ms": 0,
|
||||||
"findings": [
|
"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"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"counts": {
|
"counts": {
|
||||||
"critical": 0,
|
"critical": 0,
|
||||||
"high": 0,
|
"high": 0,
|
||||||
"medium": 0,
|
"medium": 0,
|
||||||
"low": 1,
|
"low": 0,
|
||||||
"info": 0
|
"info": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -559,15 +542,15 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aggregate": {
|
"aggregate": {
|
||||||
"total_findings": 20,
|
"total_findings": 19,
|
||||||
"counts": {
|
"counts": {
|
||||||
"critical": 0,
|
"critical": 0,
|
||||||
"high": 0,
|
"high": 0,
|
||||||
"medium": 1,
|
"medium": 1,
|
||||||
"low": 8,
|
"low": 7,
|
||||||
"info": 11
|
"info": 11
|
||||||
},
|
},
|
||||||
"risk_score": 12,
|
"risk_score": 11,
|
||||||
"risk_band": "Medium",
|
"risk_band": "Medium",
|
||||||
"verdict": "PASS",
|
"verdict": "PASS",
|
||||||
"scanners_ok": 11,
|
"scanners_ok": 11,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
[SET] Settings Validator: 0 finding(s) (1ms)
|
||||||
[HKV] Hook Validator: 0 finding(s) (1ms)
|
[HKV] Hook Validator: 0 finding(s) (1ms)
|
||||||
[RUL] Rules Validator: 0 finding(s) (1ms)
|
[RUL] Rules Validator: 0 finding(s) (1ms)
|
||||||
|
|
@ -8,18 +8,18 @@
|
||||||
[GAP] Feature Gap Scanner: 17 finding(s) (2ms)
|
[GAP] Feature Gap Scanner: 17 finding(s) (2ms)
|
||||||
[TOK] Token Hotspots: 1 finding(s) (8ms)
|
[TOK] Token Hotspots: 1 finding(s) (8ms)
|
||||||
[CPS] Cache-Prefix Stability: 0 finding(s) (1ms)
|
[CPS] Cache-Prefix Stability: 0 finding(s) (1ms)
|
||||||
[DIS] Disabled-In-Schema: 1 finding(s) (1ms)
|
[DIS] Disabled-In-Schema: 0 finding(s) (0ms)
|
||||||
[COL] Plugin Skill Collision: 0 finding(s) (0ms)
|
[COL] Plugin Skill Collision: 0 finding(s) (1ms)
|
||||||
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
Config-Audit Health Score
|
Config-Audit Health Score
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
Health: A (97/100) 9 areas scanned
|
Health: A (98/100) 9 areas scanned
|
||||||
|
|
||||||
Area Scores
|
Area Scores
|
||||||
───────────
|
───────────
|
||||||
CLAUDE.md ........... A (90) Settings ............ A (90)
|
CLAUDE.md ........... A (90) Settings ............ A (100)
|
||||||
Hooks ............... A (100) Rules ............... A (100)
|
Hooks ............... A (100) Rules ............... A (100)
|
||||||
MCP ................. A (100) Imports ............. A (100)
|
MCP ................. A (100) Imports ............. A (100)
|
||||||
Conflicts ........... A (100) Token Efficiency .... A (90)
|
Conflicts ........... A (100) Token Efficiency .... A (90)
|
||||||
|
|
|
||||||
|
|
@ -248,29 +248,16 @@
|
||||||
"category": "token-efficiency",
|
"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/<name>.json. See knowledge/cache-telemetry-recipe.md.",
|
"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/<name>.json. See knowledge/cache-telemetry-recipe.md.",
|
||||||
"autoFixable": false
|
"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": [],
|
"movedFindings": [],
|
||||||
"scoreChange": {
|
"scoreChange": {
|
||||||
"before": {
|
"before": {
|
||||||
"score": 91,
|
"score": 92,
|
||||||
"grade": "A"
|
"grade": "A"
|
||||||
},
|
},
|
||||||
"after": {
|
"after": {
|
||||||
"score": 91,
|
"score": 92,
|
||||||
"grade": "A"
|
"grade": "A"
|
||||||
},
|
},
|
||||||
"delta": 0
|
"delta": 0
|
||||||
|
|
@ -291,11 +278,11 @@
|
||||||
{
|
{
|
||||||
"name": "Settings",
|
"name": "Settings",
|
||||||
"before": {
|
"before": {
|
||||||
"score": 90,
|
"score": 100,
|
||||||
"grade": "A"
|
"grade": "A"
|
||||||
},
|
},
|
||||||
"after": {
|
"after": {
|
||||||
"score": 90,
|
"score": 100,
|
||||||
"grade": "A"
|
"grade": "A"
|
||||||
},
|
},
|
||||||
"delta": 0
|
"delta": 0
|
||||||
|
|
@ -398,8 +385,8 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"summary": {
|
"summary": {
|
||||||
"totalBefore": 20,
|
"totalBefore": 19,
|
||||||
"totalAfter": 20,
|
"totalAfter": 19,
|
||||||
"newCount": 0,
|
"newCount": 0,
|
||||||
"resolvedCount": 0,
|
"resolvedCount": 0,
|
||||||
"trend": "stable"
|
"trend": "stable"
|
||||||
|
|
|
||||||
|
|
@ -118,12 +118,6 @@
|
||||||
"title": "High MCP tool-schema budget on server \"memory\"",
|
"title": "High MCP tool-schema budget on server \"memory\"",
|
||||||
"file": ".mcp.json",
|
"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/<name>.json. See knowledge/cache-telemetry-recipe.md."
|
"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/<name>.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
|
"backupId": null
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@
|
||||||
"id": "settings",
|
"id": "settings",
|
||||||
"name": "Settings",
|
"name": "Settings",
|
||||||
"grade": "A",
|
"grade": "A",
|
||||||
"score": 90,
|
"score": 100,
|
||||||
"findingCount": 1
|
"findingCount": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "hooks",
|
"id": "hooks",
|
||||||
|
|
@ -94,7 +94,7 @@
|
||||||
"scannerEnvelope": {
|
"scannerEnvelope": {
|
||||||
"meta": {
|
"meta": {
|
||||||
"target": "/Users/ktg/repos/ktg-plugin-marketplace/config-audit/tests/fixtures/marketplace-medium",
|
"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",
|
"version": "2.2.0",
|
||||||
"tool": "config-audit"
|
"tool": "config-audit"
|
||||||
},
|
},
|
||||||
|
|
@ -103,7 +103,7 @@
|
||||||
"scanner": "CML",
|
"scanner": "CML",
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"files_scanned": 1,
|
"files_scanned": 1,
|
||||||
"duration_ms": 3,
|
"duration_ms": 2,
|
||||||
"findings": [
|
"findings": [
|
||||||
{
|
{
|
||||||
"id": "CA-CML-001",
|
"id": "CA-CML-001",
|
||||||
|
|
@ -187,7 +187,7 @@
|
||||||
"scanner": "IMP",
|
"scanner": "IMP",
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"files_scanned": 1,
|
"files_scanned": 1,
|
||||||
"duration_ms": 2,
|
"duration_ms": 1,
|
||||||
"findings": [],
|
"findings": [],
|
||||||
"counts": {
|
"counts": {
|
||||||
"critical": 0,
|
"critical": 0,
|
||||||
|
|
@ -551,26 +551,12 @@
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"files_scanned": 1,
|
"files_scanned": 1,
|
||||||
"duration_ms": 0,
|
"duration_ms": 0,
|
||||||
"findings": [
|
"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
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"counts": {
|
"counts": {
|
||||||
"critical": 0,
|
"critical": 0,
|
||||||
"high": 0,
|
"high": 0,
|
||||||
"medium": 0,
|
"medium": 0,
|
||||||
"low": 1,
|
"low": 0,
|
||||||
"info": 0
|
"info": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -578,7 +564,7 @@
|
||||||
"scanner": "COL",
|
"scanner": "COL",
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"files_scanned": 0,
|
"files_scanned": 0,
|
||||||
"duration_ms": 0,
|
"duration_ms": 1,
|
||||||
"findings": [],
|
"findings": [],
|
||||||
"counts": {
|
"counts": {
|
||||||
"critical": 0,
|
"critical": 0,
|
||||||
|
|
@ -590,15 +576,15 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aggregate": {
|
"aggregate": {
|
||||||
"total_findings": 20,
|
"total_findings": 19,
|
||||||
"counts": {
|
"counts": {
|
||||||
"critical": 0,
|
"critical": 0,
|
||||||
"high": 0,
|
"high": 0,
|
||||||
"medium": 1,
|
"medium": 1,
|
||||||
"low": 8,
|
"low": 7,
|
||||||
"info": 11
|
"info": 11
|
||||||
},
|
},
|
||||||
"risk_score": 12,
|
"risk_score": 11,
|
||||||
"risk_band": "Medium",
|
"risk_band": "Medium",
|
||||||
"verdict": "PASS",
|
"verdict": "PASS",
|
||||||
"scanners_ok": 11,
|
"scanners_ok": 11,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"meta": {
|
"meta": {
|
||||||
"target": "/Users/ktg/repos/ktg-plugin-marketplace/config-audit/tests/fixtures/marketplace-medium",
|
"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",
|
"version": "2.2.0",
|
||||||
"tool": "config-audit"
|
"tool": "config-audit"
|
||||||
},
|
},
|
||||||
|
|
@ -122,7 +122,7 @@
|
||||||
"scanner": "GAP",
|
"scanner": "GAP",
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"files_scanned": 4,
|
"files_scanned": 4,
|
||||||
"duration_ms": 2,
|
"duration_ms": 1,
|
||||||
"findings": [
|
"findings": [
|
||||||
{
|
{
|
||||||
"id": "CA-GAP-001",
|
"id": "CA-GAP-001",
|
||||||
|
|
@ -358,7 +358,7 @@
|
||||||
"scanner": "TOK",
|
"scanner": "TOK",
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"files_scanned": 2,
|
"files_scanned": 2,
|
||||||
"duration_ms": 8,
|
"duration_ms": 7,
|
||||||
"findings": [
|
"findings": [
|
||||||
{
|
{
|
||||||
"id": "CA-TOK-001",
|
"id": "CA-TOK-001",
|
||||||
|
|
@ -457,27 +457,13 @@
|
||||||
"scanner": "DIS",
|
"scanner": "DIS",
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"files_scanned": 1,
|
"files_scanned": 1,
|
||||||
"duration_ms": 1,
|
"duration_ms": 0,
|
||||||
"findings": [
|
"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
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"counts": {
|
"counts": {
|
||||||
"critical": 0,
|
"critical": 0,
|
||||||
"high": 0,
|
"high": 0,
|
||||||
"medium": 0,
|
"medium": 0,
|
||||||
"low": 1,
|
"low": 0,
|
||||||
"info": 0
|
"info": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -485,7 +471,7 @@
|
||||||
"scanner": "COL",
|
"scanner": "COL",
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"files_scanned": 0,
|
"files_scanned": 0,
|
||||||
"duration_ms": 0,
|
"duration_ms": 1,
|
||||||
"findings": [],
|
"findings": [],
|
||||||
"counts": {
|
"counts": {
|
||||||
"critical": 0,
|
"critical": 0,
|
||||||
|
|
@ -497,15 +483,15 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aggregate": {
|
"aggregate": {
|
||||||
"total_findings": 20,
|
"total_findings": 19,
|
||||||
"counts": {
|
"counts": {
|
||||||
"critical": 0,
|
"critical": 0,
|
||||||
"high": 0,
|
"high": 0,
|
||||||
"medium": 1,
|
"medium": 1,
|
||||||
"low": 8,
|
"low": 7,
|
||||||
"info": 11
|
"info": 11
|
||||||
},
|
},
|
||||||
"risk_score": 12,
|
"risk_score": 11,
|
||||||
"risk_band": "Medium",
|
"risk_band": "Medium",
|
||||||
"verdict": "PASS",
|
"verdict": "PASS",
|
||||||
"scanners_ok": 11,
|
"scanners_ok": 11,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue