CNF compared every discovered settings.json/hooks.json pairwise regardless of origin, so it treated installed plugins' bundled configs — each plugin's own settings.json/hooks.json plus its shipped test fixtures and examples under ~/.claude/plugins/ — as if they were the user's authored cascade. A "conflict" between two plugins' bundled test fixtures is not something a user can resolve, yet these dominated the count: 339 CNF findings on this machine (315 high-sev permission allow/deny "conflicts", 18 duplicate-hook, 6 settings-key), almost all sourced from plugin-internal fixtures. The Conflicts grade was F on pure noise. Fix: CNF excludes any file whose path is under `.claude/plugins/` from conflict analysis (new isPluginBundled predicate; absPath marker). Kept CNF-local rather than a discovery-level skip on purpose: an active plugin's contributed hooks.json/.mcp.json legitimately lives in plugins/cache and other scanners need it — only conflict analysis must ignore plugin-bundled files. Same class as M-BUG-8 (non-live config trees treated as live). Suite 1344/0 (+3: plugin-bundled exclusion, discovery-side sanity, over-exclusion guard). Frozen v5.0.0 + SC-5 snapshots untouched (marketplace-medium has no plugins/ paths), no re-seed. Dogfood ~/.claude CNF 339->0 (F-grade was 100% plugin-bundled noise; the ~3 genuine user-scope local settings have no actual conflicting keys, matching the plan C5 "real surface ~3 files" prediction). Follow-up (not in this fix): classifyScope tags plugin-bundled files by checking basePath instead of the file's own path, so scope:'plugin' is effectively dead for a ~/.claude-rooted scan. Fixing it would let every scanner trust the scope field, but that is a discovery-layer change beyond this bug's scope.
305 lines
12 KiB
JavaScript
305 lines
12 KiB
JavaScript
import { describe, it, beforeEach, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve, join, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
|
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
|
import { scan } from '../../scanners/conflict-detector.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const FIXTURES = resolve(__dirname, '../fixtures');
|
|
|
|
describe('CNF scanner — conflict project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'conflict-project'));
|
|
result = await scan(resolve(FIXTURES, 'conflict-project'), discovery);
|
|
});
|
|
|
|
it('returns status ok', () => {
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('reports scanner prefix CNF', () => {
|
|
assert.equal(result.scanner, 'CNF');
|
|
});
|
|
|
|
it('finding IDs match CA-CNF-NNN pattern', () => {
|
|
for (const f of result.findings) {
|
|
assert.match(f.id, /^CA-CNF-\d{3}$/);
|
|
}
|
|
});
|
|
|
|
it('detects model key conflict', () => {
|
|
assert.ok(result.findings.some(f => f.title.includes('model')));
|
|
});
|
|
|
|
it('settings conflict is medium severity', () => {
|
|
const model = result.findings.find(f => f.title.includes('model'));
|
|
assert.equal(model.severity, 'medium');
|
|
});
|
|
|
|
it('detects effortLevel key conflict', () => {
|
|
assert.ok(result.findings.some(f => f.title.includes('effortLevel')));
|
|
});
|
|
|
|
it('detects permission allow/deny conflict', () => {
|
|
assert.ok(result.findings.some(f => f.title.includes('Permission allow/deny')));
|
|
});
|
|
|
|
it('permission conflict is high severity', () => {
|
|
const perm = result.findings.find(f => f.title.includes('Permission allow/deny'));
|
|
assert.equal(perm.severity, 'high');
|
|
});
|
|
|
|
it('detects duplicate hook definition', () => {
|
|
assert.ok(result.findings.some(f => f.title.includes('Duplicate hook')));
|
|
});
|
|
|
|
it('duplicate hook is low severity', () => {
|
|
const hook = result.findings.find(f => f.title.includes('Duplicate hook'));
|
|
assert.equal(hook.severity, 'low');
|
|
});
|
|
|
|
it('has exactly 4 findings', () => {
|
|
assert.equal(result.findings.length, 4);
|
|
});
|
|
|
|
it('includes evidence with scope info', () => {
|
|
const perm = result.findings.find(f => f.title.includes('Permission'));
|
|
assert.ok(perm.evidence);
|
|
});
|
|
});
|
|
|
|
describe('CNF scanner — healthy project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
|
|
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
|
});
|
|
|
|
it('returns ok with no conflicts', () => {
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('has 0 findings', () => {
|
|
assert.equal(result.findings.length, 0);
|
|
});
|
|
});
|
|
|
|
describe('CNF scanner — empty project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
|
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
|
});
|
|
|
|
it('returns skipped when no config files', () => {
|
|
assert.equal(result.status, 'skipped');
|
|
});
|
|
|
|
it('has 0 findings', () => {
|
|
assert.equal(result.findings.length, 0);
|
|
});
|
|
});
|
|
|
|
describe('CNF scanner — minimal project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
|
|
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
|
|
});
|
|
|
|
it('returns skipped with no settings files', () => {
|
|
assert.equal(result.status, 'skipped');
|
|
});
|
|
|
|
it('has 0 findings', () => {
|
|
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);
|
|
});
|
|
});
|
|
|
|
// B3 soft-spot proof: stale ~/.claude/plugins/cache versions ship hooks.json
|
|
// that the SAME plugin also ships in its active version. CNF groups hooks by
|
|
// event:matcher across sources, so multiple cached versions inflate the
|
|
// "Duplicate hook" count. Excluding stale cache must measurably DROP CNF
|
|
// findings. This verifies the mechanism rather than assuming it.
|
|
describe('CNF scanner — cache exclusion drops duplicate-hook count (B3)', () => {
|
|
let dir, includeCount, excludeCount;
|
|
|
|
before(async () => {
|
|
dir = join(tmpdir(), `config-audit-cnf-cache-${Date.now()}`);
|
|
const pluginsDir = join(dir, 'plugins');
|
|
const versions = ['mkt/voyage/5.6.0', 'mkt/voyage/5.1.1', 'mkt/voyage/5.0.0'];
|
|
for (const key of versions) {
|
|
const verDir = join(pluginsDir, 'cache', ...key.split('/'), 'hooks');
|
|
await mkdir(verDir, { recursive: true });
|
|
await writeFile(join(verDir, 'hooks.json'),
|
|
JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'Edit' }] } }));
|
|
}
|
|
// Only 5.6.0 is active; 5.1.1 + 5.0.0 are stale.
|
|
await writeFile(join(pluginsDir, 'installed_plugins.json'), JSON.stringify({
|
|
version: 2,
|
|
plugins: {
|
|
'voyage@mkt': [{ scope: 'user', version: '5.6.0',
|
|
installPath: join(pluginsDir, 'cache', 'mkt', 'voyage', '5.6.0') }],
|
|
},
|
|
}));
|
|
|
|
resetCounter();
|
|
const dIncl = await discoverConfigFiles(dir);
|
|
includeCount = (await scan(dir, dIncl)).findings.filter(f => f.title.includes('Duplicate hook')).length;
|
|
|
|
resetCounter();
|
|
const dExcl = await discoverConfigFiles(dir, { excludeCache: true });
|
|
excludeCount = (await scan(dir, dExcl)).findings.filter(f => f.title.includes('Duplicate hook')).length;
|
|
});
|
|
|
|
after(async () => {
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('full walk surfaces ≥1 duplicate-hook finding from cached versions', () => {
|
|
assert.ok(includeCount >= 1, `expected duplicate hooks with cache, got ${includeCount}`);
|
|
});
|
|
|
|
it('excluding cache measurably drops the duplicate-hook count', () => {
|
|
assert.ok(excludeCount < includeCount,
|
|
`cache exclusion must drop CNF count: include=${includeCount} exclude=${excludeCount}`);
|
|
});
|
|
});
|
|
|
|
// M-BUG-2: CNF must segregate plugin-bundled configs from the user's authored
|
|
// cascade. Installed plugins ship their OWN settings.json / hooks.json — plus
|
|
// bundled test fixtures and examples — under ~/.claude/plugins/. None of these
|
|
// are user-authored config the user can edit, and a "conflict" between two
|
|
// plugins' bundled files is not something the user can resolve. Yet CNF compared
|
|
// every settings-json/hooks-json pairwise regardless of origin, inflating the
|
|
// Conflicts grade to F with hundreds of bogus findings (339 on this machine,
|
|
// ~315 of them high-severity permission "conflicts" between plugin test
|
|
// fixtures). CNF must exclude any file whose path is under `.claude/plugins/`.
|
|
// The exclusion belongs in CNF, NOT discovery: an active plugin's contributed
|
|
// hooks.json/.mcp.json legitimately lives in plugins/cache and other scanners
|
|
// need it — only conflict analysis must ignore it.
|
|
describe('CNF scanner — excludes plugin-bundled configs (M-BUG-2)', () => {
|
|
let dir, result, discovery;
|
|
|
|
before(async () => {
|
|
dir = join(tmpdir(), `config-audit-cnf-mbug2-${Date.now()}`);
|
|
// Live, user-authored cascade (project-scope settings).
|
|
const liveClaude = join(dir, '.claude');
|
|
await mkdir(liveClaude, { recursive: true });
|
|
await writeFile(join(liveClaude, 'settings.json'), JSON.stringify({
|
|
model: 'opus',
|
|
permissions: { deny: ['Bash(curl:*)'] },
|
|
hooks: { PreToolUse: [{ matcher: 'Edit' }] },
|
|
}));
|
|
// An installed plugin ships its OWN settings.json + hooks.json (active version).
|
|
const p1 = join(liveClaude, 'plugins', 'cache', 'mkt', 'p1', '1.0.0');
|
|
await mkdir(join(p1, '.claude'), { recursive: true });
|
|
await writeFile(join(p1, '.claude', 'settings.json'), JSON.stringify({
|
|
model: 'sonnet',
|
|
permissions: { allow: ['Bash(curl:*)'] },
|
|
hooks: { PreToolUse: [{ matcher: 'Edit' }] },
|
|
}));
|
|
await mkdir(join(p1, 'hooks'), { recursive: true });
|
|
await writeFile(join(p1, 'hooks', 'hooks.json'), JSON.stringify({
|
|
hooks: { PreToolUse: [{ matcher: 'Edit' }] },
|
|
}));
|
|
// Another plugin ships a TEST FIXTURE settings.json — pure noise, never live.
|
|
const p2fix = join(liveClaude, 'plugins', 'cache', 'mkt', 'p2', '2.0.0',
|
|
'tests', 'fixtures', 'proj', '.claude');
|
|
await mkdir(p2fix, { recursive: true });
|
|
await writeFile(join(p2fix, 'settings.json'), JSON.stringify({
|
|
model: 'haiku',
|
|
permissions: { allow: ['Bash(rm:*)'] },
|
|
}));
|
|
|
|
resetCounter();
|
|
discovery = await discoverConfigFiles(dir);
|
|
result = await scan(dir, discovery);
|
|
});
|
|
|
|
after(async () => {
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('discovery surfaces the plugin-bundled settings (exclusion must be CNF-side, not discovery-side)', () => {
|
|
const pluginSettings = discovery.files.filter(
|
|
f => f.type === 'settings-json' && f.absPath.includes(`.claude${sep}plugins${sep}`));
|
|
assert.ok(pluginSettings.length >= 2,
|
|
`expected ≥2 plugin-bundled settings discovered; got ${pluginSettings.length}`);
|
|
});
|
|
|
|
it('does not flag any conflict sourced from plugin-bundled configs', () => {
|
|
assert.equal(result.findings.length, 0,
|
|
`expected 0 CNF findings (only noise is plugin-bundled); got ${result.findings.length}: ${result.findings.map(f => f.title).join(', ')}`);
|
|
});
|
|
});
|
|
|
|
// Guard against over-exclusion: the fix must NOT silence genuine conflicts
|
|
// between the user's own authored files (user/project/local), which are not
|
|
// under `.claude/plugins/`.
|
|
describe('CNF scanner — genuine live conflict still flagged (M-BUG-2 guard)', () => {
|
|
let dir, result;
|
|
|
|
before(async () => {
|
|
dir = join(tmpdir(), `config-audit-cnf-mbug2-guard-${Date.now()}`);
|
|
const claude = join(dir, '.claude');
|
|
await mkdir(claude, { recursive: true });
|
|
await writeFile(join(claude, 'settings.json'), JSON.stringify({ model: 'opus' }));
|
|
await writeFile(join(claude, 'settings.local.json'), JSON.stringify({ model: 'haiku' }));
|
|
resetCounter();
|
|
const discovery = await discoverConfigFiles(dir);
|
|
result = await scan(dir, discovery);
|
|
});
|
|
|
|
after(async () => {
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('still flags the genuine cross-scope key conflict between user-authored files', () => {
|
|
assert.ok(result.findings.some(f => f.title.includes('model')),
|
|
`expected a genuine "model" conflict; got: ${result.findings.map(f => f.title).join(', ') || '(none)'}`);
|
|
});
|
|
});
|