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:
Kjell Tore Guttormsen 2026-06-20 11:11:12 +02:00
commit f75ed5655c
4 changed files with 105 additions and 2 deletions

View file

@ -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 });
}
});
});