diff --git a/README.md b/README.md index 4778709..6c744ff 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ![Commands](https://img.shields.io/badge/commands-18-green) ![Agents](https://img.shields.io/badge/agents-6-orange) ![Hooks](https://img.shields.io/badge/hooks-4-red) -![Tests](https://img.shields.io/badge/tests-954+-brightgreen) +![Tests](https://img.shields.io/badge/tests-957+-brightgreen) ![License](https://img.shields.io/badge/license-MIT-lightgrey) A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 13 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, and cross-plugin collision detection. Zero external dependencies. diff --git a/scanners/lib/humanizer-data.mjs b/scanners/lib/humanizer-data.mjs index 367fe4b..36dfd72 100644 --- a/scanners/lib/humanizer-data.mjs +++ b/scanners/lib/humanizer-data.mjs @@ -706,6 +706,14 @@ export const TRANSLATIONS = { }, }, patterns: [ + { + regex: /^Plugin agent sets ".+", which Claude Code ignores$/, + translation: { + title: 'A plugin agent sets a field Claude Code ignores', + description: 'Plugin subagents ignore the `hooks`, `mcpServers`, and `permissionMode` settings — only agents in `.claude/agents/` honor them. The field shown here has no effect, and `permissionMode` can give a false sense of restriction.', + recommendation: 'Remove the field, or move the agent into `.claude/agents/`, where it takes effect.', + }, + }, { regex: /^Missing required field in plugin\.json/, translation: { diff --git a/scanners/plugin-health-scanner.mjs b/scanners/plugin-health-scanner.mjs index 1bffbfc..66dd68c 100644 --- a/scanners/plugin-health-scanner.mjs +++ b/scanners/plugin-health-scanner.mjs @@ -47,6 +47,17 @@ const REQUIRED_AGENT_FRONTMATTER = [ { key: 'description', display: 'description' }, ]; +// Plugin subagents silently ignore these frontmatter keys — they are honored +// ONLY for user/project agents in .claude/agents/ (code.claude.com/docs +// sub-agents, "ignored for plugin subagents"). Setting them in a plugin agent +// is dead config; permissionMode is MEDIUM because it implies a restriction +// that Claude Code does not actually apply (false sense of security). +const PLUGIN_AGENT_IGNORED_FIELDS = [ + { key: 'permissionMode', severity: SEVERITY.medium }, + { key: 'hooks', severity: SEVERITY.low }, + { key: 'mcpServers', severity: SEVERITY.low }, +]; + // Component-path keys that REPLACE the default folder (per code.claude.com/docs // plugins-reference#path-behavior-rules). When such a key is set, Claude Code // stops scanning the default folder; if that folder still exists, its contents @@ -419,6 +430,23 @@ async function scanSinglePlugin(pluginDir) { })); } } + + // Plugin subagents ignore hooks/mcpServers/permissionMode frontmatter (V15) + // — dead config (permissionMode = medium: false sense of restriction). + for (const { key, severity } of PLUGIN_AGENT_IGNORED_FIELDS) { + if (frontmatter[key] !== undefined) { + findings.push(finding({ + scanner: SCANNER, + severity, + title: `Plugin agent sets "${key}", which Claude Code ignores`, + description: `Agent "${file}" in plugin "${pluginName}" sets "${key}" in frontmatter, but Claude Code ignores ${key} for plugin subagents — ${key === 'permissionMode' ? 'the agent runs with default permissions, not the restricted mode this implies' : 'this configuration has no effect'}.`, + file: filePath, + evidence: `${key}: ${JSON.stringify(frontmatter[key])}`, + recommendation: `Remove "${key}" from the agent frontmatter, or ship the agent as a user/project agent in .claude/agents/, where ${key} is honored.`, + autoFixable: false, + })); + } + } } } catch { /* no agents dir */ } diff --git a/tests/scanners/plugin-health-scanner.test.mjs b/tests/scanners/plugin-health-scanner.test.mjs index e9fe9ab..163689e 100644 --- a/tests/scanners/plugin-health-scanner.test.mjs +++ b/tests/scanners/plugin-health-scanner.test.mjs @@ -1,7 +1,9 @@ -import { describe, it } from 'node:test'; +import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { resetCounter } from '../../scanners/lib/output.mjs'; import { scan, discoverPlugins } from '../../scanners/plugin-health-scanner.mjs'; @@ -356,3 +358,68 @@ describe('finding format', () => { assert.ok(f.description); }); }); + +describe('PLH — plugin agent declares fields Claude Code ignores (E)', () => { + // Hermetic temp fixture: the path-guard blocks committing .claude-plugin/. + // Plugin subagents silently ignore hooks/mcpServers/permissionMode frontmatter + // (code.claude.com/docs sub-agents, V15) — dead config; permissionMode = false security. + let tmpRoot; + let result; + + async function writePlugin(root, agentExtraFrontmatter) { + await mkdir(join(root, '.claude-plugin'), { recursive: true }); + await mkdir(join(root, 'agents'), { recursive: true }); + await writeFile( + join(root, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'demo', description: 'demo plugin for tests', version: '1.0.0' }, null, 2) + '\n', + 'utf8', + ); + await writeFile( + join(root, 'agents', 'doer.md'), + `---\nname: doer\ndescription: does things\n${agentExtraFrontmatter}---\n\n# Doer\n\nBody.\n`, + 'utf8', + ); + } + + beforeEach(async () => { + resetCounter(); + tmpRoot = await mkdtemp(join(tmpdir(), 'ca-plh-agentdead-')); + await writePlugin(tmpRoot, 'permissionMode: plan\nhooks: present\nmcpServers: present\n'); + result = await scan(tmpRoot); + }); + + afterEach(async () => { + if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true }); + }); + + it('flags permissionMode as medium (false security)', () => { + const f = result.findings.find(x => + x.scanner === 'PLH' && (x.evidence || '').startsWith('permissionMode:')); + assert.ok(f, `expected a permissionMode finding; got: ${result.findings.map(x => x.title).join(' | ')}`); + assert.equal(f.severity, 'medium'); + }); + + it('flags hooks and mcpServers as low (dead config)', () => { + for (const key of ['hooks', 'mcpServers']) { + const f = result.findings.find(x => + x.scanner === 'PLH' && (x.evidence || '').startsWith(`${key}:`)); + assert.ok(f, `expected a ${key} finding`); + assert.equal(f.severity, 'low', `${key} should be low`); + } + }); + + it('does NOT flag a clean agent (name + description only)', async () => { + resetCounter(); + const clean = await mkdtemp(join(tmpdir(), 'ca-plh-agentclean-')); + try { + await writePlugin(clean, ''); + const r = await scan(clean); + const dead = r.findings.filter(x => + x.scanner === 'PLH' && /which Claude Code ignores/.test(x.title || '')); + assert.equal(dead.length, 0, + `clean agent should have no ignored-field findings; got: ${dead.map(x => x.title).join(' | ')}`); + } finally { + await rm(clean, { recursive: true, force: true }); + } + }); +});