feat(plh): flag plugin-agent frontmatter Claude Code ignores (v5.5.0 / E)
Plugin subagents silently ignore `hooks`/`mcpServers`/`permissionMode` frontmatter — these are honored only for user/project agents in .claude/agents/ (code.claude.com/docs sub-agents). Setting them in a plugin agent is dead config; `permissionMode` is MEDIUM because it implies a restriction Claude Code does not apply (false security). hooks/mcpServers are LOW. Additive to PLH's agent-frontmatter loop (no new scanner, count stays 13). One humanizer pattern covers the three field titles. Hermetic temp-fixture test (positive + negative). Suite 954 -> 957, byte-stable. Part of v5.5.0 "steering-model I". Foundation (active-config-reader enumeration) deferred to v5.6 with B — A/E are additive and don't consume it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
This commit is contained in:
parent
73a7f117d6
commit
f75ed5655c
4 changed files with 105 additions and 2 deletions
|
|
@ -12,7 +12,7 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue