Background
This grew out of a report of "leftover pre-skilllint linting scripts" in an
unrelated repo. That premise was false (already clean on main there); the real
find was a WIP script on another branch implementing a "runtime escape" scanner —
markdown-link/path checks with no tests, hardcoded heuristics, and no spec
citations. Rather than port that design, every claim in it was independently
checked against official Claude Code docs and fresh vendor source clones for
Codex, Kimi, Kilo Code, and OpenCode (.claude/vendor/, kept in sync as of #228).
Most of the original 4-detector design didn't survive that check — see "Rejected"
below. Two things did survive, both spec-backed and both real gaps in skilllint
today.
1. skilllint never verifies a plugin-root escape, despite three rules that sound like it does
Per Claude Code's official docs (plugins-reference.md, "Path traversal
limitations", fetched via skilllint docs fetch, verbatim):
Claude Code doesn't let a plugin reference files outside its own directory. It
rejects a component path that resolves outside the plugin root, whether the
path is declared in plugin.json or in a marketplace entry... and a symlink
that leads outside the plugin... When Claude Code rejects a path, it reports a
path escapes plugin directory error and loads the plugin without that
component.
Scope, precisely: this applies to (a) plugin.json/marketplace.json
component-path fields and (b) symlinks. It does NOT apply to markdown
links or bare paths in SKILL.md/agent prose (confirmed: installation copies a
plugin's own files verbatim, per the same doc's "Plugin caching and file
resolution" section — internal relative paths behave identically before and
after install; nothing in the docs shows the harness mechanically following a
plain markdown link in body text).
Checked what skilllint actually does today for (a) and (b) — verified by reading
each function, not assumed:
- PL004 (
packages/skilllint/rules/pl_series.py:467-495) and PL005
(:503-541) — both are pure regex matches against the stderr text of
claude plugin validate (patterns at pl_series.py:128-129). Neither reads
plugin.json's component-path arrays, joins them against the plugin root, or
calls .resolve(). If the vendor CLI's wording doesn't match, or isn't run, an
out-of-root ../shared-utils component path is invisible to skilllint. PL005's
own docstring says so outright: "skilllint performs no filesystem presence
check of its own for this rule."
- SL001 (
packages/skilllint/rules/sl_series.py:87-161) — only compares a
symlink's raw readlink() target against its .rstrip()'d form to catch
trailing whitespace/newlines (:140). Never resolves the target, never checks
it against the plugin root.
- NR002 (
packages/skilllint/rules/nr_series.py:555-642) cites the same
authority URL and looks like it should cover this, but it only checks
namespace-reference strings (plugin:skill syntax) for literal .., /,
\ characters (_has_path_traversal, :425-434) — a lexical ban on
characters in a reference token, never a real filesystem resolve+contain check.
It doesn't touch plugin.json's declared paths or symlinks at all.
Net: zero code anywhere in skilllint independently resolves a declared
component path or symlink target against the plugin root and rejects an actual
escape. The one enforcement mechanism the spec describes is delegated entirely
to hoping the vendor CLI's stderr wording matches a regex.
What to build: extend PL004/PL005 (or add a sibling rule in the PL series)
to resolve each plugin.json/marketplace.json component path against the
plugin root and flag it if .resolve() lands outside — reuse find_plugin_dir
(packages/skilllint/plugin_validator.py:172-184), the existing marker-based
(.claude-plugin/plugin.json) boundary primitive already imported by LK001
(lk_series.py:181-186) — don't invent a new directory-name climb like the
prior-art script's _PLUGIN_ESCAPE_DEPTH = 3 (an uncited numeric threshold that
would fail this repo's own "no invented constraints" bar). Do the same for SL001
— resolve the symlink target and check is_relative_to(plugin_root), alongside
the existing whitespace check.
2. Portability: ${CLAUDE_PLUGIN_ROOT}-anchored paths are Claude-Code-only; skill-relative paths are not
Independently verified against fresh vendor source (git HEADs from
2026-09-01 through 2026-09-08, post-#228 fix — not a stale snapshot):
| Platform |
Inline substitution in skill/agent body content |
Evidence |
| Claude Code |
Confirmed — docs state ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_SKILL_DIR}/${CLAUDE_PLUGIN_DATA} substitute "anywhere the placeholder appears" |
skills.md, plugins-reference.md |
| Codex |
Not found for skill/agent body text. (It does export CLAUDE_PLUGIN_ROOT/CLAUDE_PLUGIN_DATA as env vars to plugin hook subprocesses, explicitly "For OOTB compat with existing plugins that use this env var" — but that's process env, not textual substitution in markdown.) |
codex-rs/hooks/src/engine/discovery.rs:265-269, HEAD 54e04f25d |
| Kimi |
Refuted — docs explicitly instruct "Use relative paths in SKILL.md to reference other files" |
docs/en/customization/skills.md:179 |
| Kilo Code |
Refuted — no substitution mechanism documented |
packages/kilo-docs/pages/customize/skills.md |
| OpenCode |
Refuted — skill content passed through verbatim, no substitution call in the load or invoke path |
packages/opencode/src/skill/index.ts, src/tool/skill.ts |
| Cursor, Crush, Hermes |
Unverifiable (Cursor: doc site is JS-rendered, no static content even on a fresh fetch; Crush/Hermes: no vendor source exists to check) — not claimed either way |
|
Of every platform where a skills concept exists and was actually checked, only
Claude Code documents substituting a plugin-root placeholder inside skill body
prose. A path that needs the agent to find a file inside the same skill's own
directory is expressible as a plain relative path — no substitution needed on
any platform, and Kimi's docs recommend exactly this. A path anchored on
${CLAUDE_PLUGIN_ROOT} to reach something outside the skill's own directory
(e.g. plugin-root-level shared content) only resolves today on Claude Code.
What to build: a rule (platform-scoped to claude-code, since the token
itself is Claude-Code-specific) that flags ${CLAUDE_PLUGIN_ROOT} /
${CLAUDE_PLUGIN_DATA} usage in a skill's body where the resolved target falls
inside that same skill's own directory — in that case the substitution is
unnecessary and a relative path would work identically and more portably. Don't
flag usage reaching genuinely shared, plugin-root-level content outside the
skill directory; that's the one case where the variable is actually earning its
keep, and the rule should say so rather than blanket-discourage the variable.
Rejected from the original 4-detector design (recorded so it isn't re-proposed without re-deriving why)
- Markdown link resolving outside the plugin root — not a real runtime
defect per the docs above (prose links are inert; the harness's traversal
rejection only applies to manifest component paths and symlinks, not body
text). Downgraded to the doc-hygiene note below.
- Bare authoring-repo path in runtime text (hardcoded dir-name list:
rules|tests|tests_backlog|examples|research) — same "not a real runtime
defect" reason, plus the hardcoded list itself has no spec source and would
fail this repo's "no invented constraints" bar.
- Cross-plugin filesystem path in prose — same reason as the link case.
- Design-time-link classification (flagging links to
MAINTENANCE.md /
SKILL-GOALS.md / BENCHMARKS.md / maintenance/ / evals/ as "ships but
never loads") — skilllint has zero existing concept of design-time-vs-runtime
file classification (grep confirms), and a filename allowlist here has no
spec, config, or library default behind it. Not a lint rule; at most a
target-repo-specific authoring convention, never skilllint's to invent.
Doc-hygiene note (not a lint rule — recorded for awareness only)
A misleading or dead-in-spirit cross-repo link in skill prose can't break
mechanically (see above), but it can still mislead the model into wasting a
turn trying to read something that won't resolve for a real user, or reading the
wrong file if a same-named one happens to exist locally. This is an authoring
convention, not something skilllint should mechanically enforce absent a cited
spec — flagging it here so it isn't lost, not proposing a rule for it.
Provenance / methodology
Checked via skilllint docs fetch against live Claude Code docs, plus
freshly-synced local vendor clones for Codex/Kimi/Kilo Code/OpenCode (the sync
script itself had a bug — fixed in #228 — that had silently frozen those clones
at a 2026-03-23 snapshot; every claim above was re-verified after that fix, on
HEADs dated 2026-09-01 through 2026-09-08). No claim here is copied from the
prior-art script or from training-data assumptions about any of these tools.
Background
This grew out of a report of "leftover pre-skilllint linting scripts" in an
unrelated repo. That premise was false (already clean on
mainthere); the realfind was a WIP script on another branch implementing a "runtime escape" scanner —
markdown-link/path checks with no tests, hardcoded heuristics, and no spec
citations. Rather than port that design, every claim in it was independently
checked against official Claude Code docs and fresh vendor source clones for
Codex, Kimi, Kilo Code, and OpenCode (
.claude/vendor/, kept in sync as of #228).Most of the original 4-detector design didn't survive that check — see "Rejected"
below. Two things did survive, both spec-backed and both real gaps in skilllint
today.
1. skilllint never verifies a plugin-root escape, despite three rules that sound like it does
Per Claude Code's official docs (
plugins-reference.md, "Path traversallimitations", fetched via
skilllint docs fetch, verbatim):Scope, precisely: this applies to (a)
plugin.json/marketplace.jsoncomponent-path fields and (b) symlinks. It does NOT apply to markdown
links or bare paths in SKILL.md/agent prose (confirmed: installation copies a
plugin's own files verbatim, per the same doc's "Plugin caching and file
resolution" section — internal relative paths behave identically before and
after install; nothing in the docs shows the harness mechanically following a
plain markdown link in body text).
Checked what skilllint actually does today for (a) and (b) — verified by reading
each function, not assumed:
packages/skilllint/rules/pl_series.py:467-495) and PL005(
:503-541) — both are pure regex matches against the stderr text ofclaude plugin validate(patterns atpl_series.py:128-129). Neither readsplugin.json's component-path arrays, joins them against the plugin root, orcalls
.resolve(). If the vendor CLI's wording doesn't match, or isn't run, anout-of-root
../shared-utilscomponent path is invisible to skilllint. PL005'sown docstring says so outright: "skilllint performs no filesystem presence
check of its own for this rule."
packages/skilllint/rules/sl_series.py:87-161) — only compares asymlink's raw
readlink()target against its.rstrip()'d form to catchtrailing whitespace/newlines (
:140). Never resolves the target, never checksit against the plugin root.
packages/skilllint/rules/nr_series.py:555-642) cites the sameauthority URL and looks like it should cover this, but it only checks
namespace-reference strings (
plugin:skillsyntax) for literal..,/,\characters (_has_path_traversal,:425-434) — a lexical ban oncharacters in a reference token, never a real filesystem resolve+contain check.
It doesn't touch
plugin.json's declared paths or symlinks at all.Net: zero code anywhere in skilllint independently resolves a declared
component path or symlink target against the plugin root and rejects an actual
escape. The one enforcement mechanism the spec describes is delegated entirely
to hoping the vendor CLI's stderr wording matches a regex.
What to build: extend PL004/PL005 (or add a sibling rule in the PL series)
to resolve each
plugin.json/marketplace.jsoncomponent path against theplugin root and flag it if
.resolve()lands outside — reusefind_plugin_dir(
packages/skilllint/plugin_validator.py:172-184), the existing marker-based(
.claude-plugin/plugin.json) boundary primitive already imported by LK001(
lk_series.py:181-186) — don't invent a new directory-name climb like theprior-art script's
_PLUGIN_ESCAPE_DEPTH = 3(an uncited numeric threshold thatwould fail this repo's own "no invented constraints" bar). Do the same for SL001
— resolve the symlink target and check
is_relative_to(plugin_root), alongsidethe existing whitespace check.
2. Portability:
${CLAUDE_PLUGIN_ROOT}-anchored paths are Claude-Code-only; skill-relative paths are notIndependently verified against fresh vendor source (git HEADs from
2026-09-01 through 2026-09-08, post-#228 fix — not a stale snapshot):
${CLAUDE_PLUGIN_ROOT}/${CLAUDE_SKILL_DIR}/${CLAUDE_PLUGIN_DATA}substitute "anywhere the placeholder appears"skills.md,plugins-reference.mdCLAUDE_PLUGIN_ROOT/CLAUDE_PLUGIN_DATAas env vars to plugin hook subprocesses, explicitly "For OOTB compat with existing plugins that use this env var" — but that's process env, not textual substitution in markdown.)codex-rs/hooks/src/engine/discovery.rs:265-269, HEAD54e04f25dSKILL.mdto reference other files"docs/en/customization/skills.md:179packages/kilo-docs/pages/customize/skills.mdpackages/opencode/src/skill/index.ts,src/tool/skill.tsOf every platform where a skills concept exists and was actually checked, only
Claude Code documents substituting a plugin-root placeholder inside skill body
prose. A path that needs the agent to find a file inside the same skill's own
directory is expressible as a plain relative path — no substitution needed on
any platform, and Kimi's docs recommend exactly this. A path anchored on
${CLAUDE_PLUGIN_ROOT}to reach something outside the skill's own directory(e.g. plugin-root-level shared content) only resolves today on Claude Code.
What to build: a rule (platform-scoped to
claude-code, since the tokenitself is Claude-Code-specific) that flags
${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA}usage in a skill's body where the resolved target fallsinside that same skill's own directory — in that case the substitution is
unnecessary and a relative path would work identically and more portably. Don't
flag usage reaching genuinely shared, plugin-root-level content outside the
skill directory; that's the one case where the variable is actually earning its
keep, and the rule should say so rather than blanket-discourage the variable.
Rejected from the original 4-detector design (recorded so it isn't re-proposed without re-deriving why)
defect per the docs above (prose links are inert; the harness's traversal
rejection only applies to manifest component paths and symlinks, not body
text). Downgraded to the doc-hygiene note below.
rules|tests|tests_backlog|examples|research) — same "not a real runtimedefect" reason, plus the hardcoded list itself has no spec source and would
fail this repo's "no invented constraints" bar.
MAINTENANCE.md/SKILL-GOALS.md/BENCHMARKS.md/maintenance//evals/as "ships butnever loads") — skilllint has zero existing concept of design-time-vs-runtime
file classification (
grepconfirms), and a filename allowlist here has nospec, config, or library default behind it. Not a lint rule; at most a
target-repo-specific authoring convention, never skilllint's to invent.
Doc-hygiene note (not a lint rule — recorded for awareness only)
A misleading or dead-in-spirit cross-repo link in skill prose can't break
mechanically (see above), but it can still mislead the model into wasting a
turn trying to read something that won't resolve for a real user, or reading the
wrong file if a same-named one happens to exist locally. This is an authoring
convention, not something skilllint should mechanically enforce absent a cited
spec — flagging it here so it isn't lost, not proposing a rule for it.
Provenance / methodology
Checked via
skilllint docs fetchagainst live Claude Code docs, plusfreshly-synced local vendor clones for Codex/Kimi/Kilo Code/OpenCode (the sync
script itself had a bug — fixed in #228 — that had silently frozen those clones
at a 2026-03-23 snapshot; every claim above was re-verified after that fix, on
HEADs dated 2026-09-01 through 2026-09-08). No claim here is copied from the
prior-art script or from training-data assumptions about any of these tools.