feat(plh): validate plugin.json skills:-array entries (CA-PLH-016)

PLH now validates each entry of a plugin.json `skills` field (string|array):
every entry must resolve to an existing directory inside the plugin root.
One finding per bad entry (medium, plugin-hygiene), problem ∈ {non-string,
escapes-root, not-found, not-a-directory}. Mirrors `claude plugin validate`.
String|array normalized so a non-string top-level value is caught too.

Verifiseringsplikt: the plan's "CC suggests the parent directory" error text
is NOT in the primary docs — dropped. Only the four primary-source-verified
conditions are asserted (escape backed by the path-traversal rule).

Tests +3 (941->944). Scanner count unchanged (13). --json/--raw byte-stable.

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-19 21:56:57 +02:00
commit 76d5eda101
9 changed files with 189 additions and 2 deletions

View file

@ -201,6 +201,26 @@ flagged, because Claude Code keeps scanning the folder in that case (`addressesD
predicate). The v5.4.0 plan originally listed `commands/agents/skills/hooks`; that set was
corrected here against the live docs (Verifiseringsplikt).
### PLH scanner — skills:-array validation (`CA-PLH-016`)
Per-plugin check (in `scanSinglePlugin`, after the shadow check): when `plugin.json` has a
`skills` field (string or array), each entry must resolve to an **existing directory inside the
plugin root**. The value is normalized `Array.isArray(v) ? v : [v]`, so a single string is one
entry — and a non-string top-level value (e.g. `42`) is naturally caught as a single non-string
entry (no separate top-level check needed). One finding per bad entry, severity **MEDIUM**,
`category: 'plugin-hygiene'`, `details: { field: 'skills', entry, problem }` where `problem` is
one of `non-string` / `escapes-root` / `not-found` / `not-a-directory`. Mirrors
`claude plugin validate` (~2.1.145).
Escape detection uses `skillsEntryEscapesRoot` (resolve + `startsWith(pluginDir + sep)`
containment — robust against a literal `..foo` dir name), backed by the docs' path-traversal rule
(*"Installed plugins cannot reference files outside their directory … such as `../shared-utils`"*).
`statOrNull` distinguishes missing from file-vs-dir. **Verifiseringsplikt note:** the v5.4.0 plan
claimed CC "suggests the parent directory when an entry points at a file"; that exact error text is
**not** in the primary docs, so it was dropped — the finding asserts only the four
primary-source-verified conditions. `skills` is deliberately *not* in `SHADOWING_PATH_FIELDS`
(it adds to the default scan, never shadows).
## Gotchas
- Session directories accumulate — use `/config-audit cleanup` to manage