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:
parent
9b63125f6a
commit
76d5eda101
9 changed files with 189 additions and 2 deletions
|
|
@ -742,6 +742,14 @@ export const TRANSLATIONS = {
|
|||
recommendation: 'Either delete the unused default folder, or keep it by listing it explicitly alongside the custom path. The details show which folder and field.',
|
||||
},
|
||||
},
|
||||
{
|
||||
regex: /^plugin\.json "skills" entry /,
|
||||
translation: {
|
||||
title: 'A plugin lists a skill path that doesn\'t point to a real skill folder',
|
||||
description: 'The plugin\'s `plugin.json` lists a `skills` path that isn\'t a folder inside the plugin — it\'s missing, points at a file, sits outside the plugin, or isn\'t text. Claude Code loads no skill from it.',
|
||||
recommendation: 'Point each `skills` entry at a real folder inside the plugin that contains a `SKILL.md`. The details show which entry and what\'s wrong.',
|
||||
},
|
||||
},
|
||||
],
|
||||
_default: {
|
||||
title: 'A plugin has a configuration issue',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*/
|
||||
|
||||
import { readdir, stat, readFile } from 'node:fs/promises';
|
||||
import { join, basename, resolve } from 'node:path';
|
||||
import { join, basename, resolve, sep } from 'node:path';
|
||||
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
|
|
@ -71,6 +71,50 @@ async function dirExists(p) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Stat `p`, or null when it does not exist (distinguishes missing from file/dir). */
|
||||
async function statOrNull(p) {
|
||||
try {
|
||||
return await stat(p);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a `skills` entry resolves outside the plugin root. Installed plugins
|
||||
* cannot reference files outside their own directory (docs: path-traversal
|
||||
* limitations), so "../shared" or an absolute path will not load.
|
||||
*/
|
||||
function skillsEntryEscapesRoot(pluginDir, entry) {
|
||||
const resolved = resolve(pluginDir, entry.replace(/^\.\//, ''));
|
||||
return resolved !== pluginDir && !resolved.startsWith(pluginDir + sep);
|
||||
}
|
||||
|
||||
// Per-problem prose for a malformed `skills` entry. Each `title` starts with
|
||||
// `plugin.json "skills" entry` so the family is greppable.
|
||||
const SKILLS_ENTRY_MESSAGES = {
|
||||
'non-string': {
|
||||
title: e => `plugin.json "skills" entry is not a string: ${JSON.stringify(e)}`,
|
||||
description: 'Each "skills" entry must be a relative path string (starting with "./") to a skill directory.',
|
||||
recommendation: 'Replace the non-string entry with a path like "./my-skill/", or remove it.',
|
||||
},
|
||||
'escapes-root': {
|
||||
title: e => `plugin.json "skills" entry escapes the plugin root: ${e}`,
|
||||
description: 'Installed plugins cannot reference files outside their own directory, so a skills path that traverses outside the plugin root (e.g. "../shared") will not load.',
|
||||
recommendation: 'Point the entry at a directory inside the plugin, or vendor the skill into the plugin.',
|
||||
},
|
||||
'not-found': {
|
||||
title: e => `plugin.json "skills" entry does not exist: ${e}`,
|
||||
description: 'The "skills" entry points at a path that does not exist in the plugin, so no skill loads from it.',
|
||||
recommendation: 'Create the directory, fix the path, or remove the entry.',
|
||||
},
|
||||
'not-a-directory': {
|
||||
title: e => `plugin.json "skills" entry is a file, not a directory: ${e}`,
|
||||
description: 'A "skills" entry must be a directory containing a SKILL.md (or <name>/SKILL.md), not a file.',
|
||||
recommendation: 'Point the entry at the skill directory (the folder that contains SKILL.md), not the file.',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Discover plugins under a path.
|
||||
* Looks for .claude-plugin/plugin.json pattern.
|
||||
|
|
@ -204,6 +248,40 @@ async function scanSinglePlugin(pluginDir) {
|
|||
details: { field: key, ignoredDir: defaultDir, customPaths },
|
||||
}));
|
||||
}
|
||||
|
||||
// skills:-array validation: each entry must resolve to an existing
|
||||
// directory inside the plugin root. Mirrors `claude plugin validate`.
|
||||
// skills is string|array (a single string is one entry). Unlike the
|
||||
// shadow check, skills ADDS to the default skills/ scan, so a custom path
|
||||
// here is never a shadow — it just has to be a real directory.
|
||||
if (parsed.skills !== undefined && parsed.skills !== null) {
|
||||
const entries = Array.isArray(parsed.skills) ? parsed.skills : [parsed.skills];
|
||||
for (const entry of entries) {
|
||||
let problem = null;
|
||||
if (typeof entry !== 'string') {
|
||||
problem = 'non-string';
|
||||
} else if (skillsEntryEscapesRoot(pluginDir, entry)) {
|
||||
problem = 'escapes-root';
|
||||
} else {
|
||||
const st = await statOrNull(resolve(pluginDir, entry.replace(/^\.\//, '')));
|
||||
if (!st) problem = 'not-found';
|
||||
else if (!st.isDirectory()) problem = 'not-a-directory';
|
||||
}
|
||||
if (!problem) continue;
|
||||
const m = SKILLS_ENTRY_MESSAGES[problem];
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: m.title(entry),
|
||||
description: `Plugin "${pluginName}": ${m.description}`,
|
||||
file: pluginJsonPath,
|
||||
evidence: `skills entry=${JSON.stringify(entry)}; problem=${problem}`,
|
||||
recommendation: m.recommendation,
|
||||
category: 'plugin-hygiene',
|
||||
details: { field: 'skills', entry, problem },
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
findings.push(finding({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue