Skip to content

feat: verify plugin-root escape for component paths/symlinks; flag unnecessary ${CLAUDE_PLUGIN_ROOT} use #229

Description

@Jamie-BitFlight

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions