[BUG] v2.1.136 regression: marketplace entry `"skills": ["./"]` rejected with `Path escapes plugin directory` for plugins without plugin.json

Resolved 💬 5 comments Opened May 9, 2026 by qishaoyumu Closed May 15, 2026

[BUG] v2.1.136 regression: marketplace entry "skills": ["./"] rejected with Path escapes plugin directory for plugins without plugin.json

Summary

A marketplace.json plugin entry with "skills": ["./"], "strict": false, and a source directory whose root contains SKILL.md directly fails to load:

<plugin-name> (user)
  skills path escapes plugin directory: ./
  Paths in plugin.json must not use ".." to reference files outside the plugin directory

plugins-reference#path-behavior-rules documents this pattern as supported:

When a skill path points to a directory that contains a SKILL.md directly, for example "skills": ["./"] pointing to the plugin root, the frontmatter name field in SKILL.md determines the skill's invocation name. This gives a stable name regardless of the install directory.

v2.1.94 introduced it explicitly (CHANGELOG):

Plugin skills declared via "skills": ["./"] now use the skill's frontmatter name for the invocation name instead of the directory basename, giving a stable name across install methods

Reproduction

Directory layout:

test-marketplace/
├── .claude-plugin/
│   └── marketplace.json
└── plugin-dir/
    └── SKILL.md           # frontmatter: name: test-skill, description: ...

Place at test-marketplace/.claude-plugin/marketplace.json:

{
  "name": "test-marketplace",
  "owner": { "name": "Test" },
  "plugins": [
    {
      "name": "test-plugin",
      "source": "./plugin-dir",
      "skills": ["./"],
      "strict": false
    }
  ]
}

test-marketplace/plugin-dir/SKILL.md content:

---
name: test-skill
description: test skill
---
test body

Steps (the @test-marketplace suffix matches the name field of marketplace.json):

/plugin marketplace add /path/to/test-marketplace
/plugin install test-plugin@test-marketplace
/reload-plugins

On v2.1.136 / v2.1.137 / v2.1.138: 1 error during load. /doctor reports the error quoted in Summary.

On v2.1.94 through v2.1.133: the plugin loads and /test-plugin:test-skill becomes available. End-to-end confirmed on v2.1.128.

Bisect

The Checking skill path: debug string disappears from the platform binary (@anthropic-ai/claude-code-darwin-arm64@<version>) between v2.1.133 and v2.1.136. User-observed plugin-load failures start at v2.1.136. No failures on v2.1.133 or earlier.

This is a proxy for code change, not a direct observation of the new error path. The marketplace-entry skills handling in v2.1.94–v2.1.133 contained four distinctive debug log strings: Processing N skill paths for plugin X, Checking skill path: J -> D (exists: M), Found N valid skill paths for plugin X, setting skillsPaths, and Plugin X has no entry.skills defined. Checking skill path: works as a marker because the v2.1.128 binary's strings output contains it nowhere else.

| Version | npm release date (UTC) | Checking skill path: occurrences in binary | Behavior |
|---|---|---|---|
| 2.1.128 | 2026-05-04 | 3 | works (user-confirmed) |
| 2.1.131 | 2026-05-06 | 3 | works (inferred) |
| 2.1.132 | 2026-05-06 | 3 | works (inferred) |
| 2.1.133 | 2026-05-07 | 2 | partial refactor |
| 2.1.136 | 2026-05-08 | 0 | fails |
| 2.1.137 | 2026-05-09 | 0 | fails |
| 2.1.138 | 2026-05-09 | 0 | fails |

The closest matching v2.1.136 CHANGELOG entry:

Fixed a skills entry in plugin.json hiding the plugin's default skills/ directory, and listing a file path now shows an error instead of failing silently

Observations from binary inspection

Comparing the v2.1.128 and v2.1.137 darwin-arm64 binaries:

(1) The path validation function is unchanged across versions. Both binaries contain a function that returns null when path.relative(pluginRoot, path.resolve(pluginRoot, skillPath)) returns the empty string:

// Same logic in both v2.1.128 and v2.1.137
function validate(H, _) {
  let q = path.resolve(H),
      K = path.resolve(q, _),
      O = path.relative(q, K);
  if (O === "" || O.startsWith("..") || path.resolve(O) === O) return null;
  return K;
}

For _ === "./", path.relative returns "" because K === q, so the function returns null. Callers treat the null return as path-traversal, which renders as "<component> path escapes plugin directory: <path>".

(2) The marketplace-entry skills handling in v2.1.128 bypassed this function. The v2.1.128 binary contains this code (offset 0xBFD3258 / 201284952 in claude-code-darwin-arm64@2.1.128):

if (H.skills) {
  y(`Processing ${Array.isArray(H.skills) ? H.skills.length : 1} skill paths for plugin ${H.name}`);
  let Y = Array.isArray(H.skills) ? H.skills : [H.skills],
      w = await Promise.all(Y.map(async (J) => {
        let D = path.join(O, J);  // direct path.join, no validation
        return { skillPath: J, fullPath: D, exists: await fileExists(D) };
      }));
  let j = [];
  for (let { skillPath: J, fullPath: D, exists: M } of w)
    if (y(`Checking skill path: ${J} -> ${D} (exists: ${M})`), M)
      j.push(D);
    else
      // path-not-found error pushed (not path-traversal)
      ...
  if (y(`Found ${j.length} valid skill paths for plugin ${H.name}, setting skillsPaths`), j.length > 0)
    A.skillsPaths = j;
}

This path uses path.join (which resolves ./ to the plugin root) and never calls the validation function. The only error type it can emit is path-not-found.

The v2.1.137 binary no longer contains the Checking skill path: debug log or the surrounding distinctive strings. The containing function still retains Processing N skill paths for plugin X, valid skill paths for plugin, and has no entry.skills defined, so the marketplace-entry skills field is still being processed, but through a different code path.

I could not extract the v2.1.137 handler code cleanly. The surrounding bytes did not yield readable JS via strings the way v2.1.128 did. The inference that v2.1.137 now routes this field through validate instead of path.join is consistent with the path-traversal error and with the missing v2.1.128-era debug strings, but is not directly proven from the binary disassembly. Maintainers can confirm or refute by inspecting the v2.1.136 diff for the manifest-loading code.

Documentation status

plugins-reference#path-behavior-rules currently states:

When a skill path points to a directory that contains a SKILL.md directly, for example "skills": ["./"] pointing to the plugin root, the frontmatter name field in SKILL.md determines the skill's invocation name.

The docs have not been updated for the new behavior. No deprecation note or removal-of-support announcement appears in CHANGELOG entries between v2.1.94 and v2.1.138.

The current error message text is:

Paths in plugin.json must not use ".." to reference files outside the plugin directory

The text mentions .., but the trigger here is path.relative(pluginRoot, path.resolve(pluginRoot, "./")) returning "" (the resolved path equals the plugin root itself), not a .. traversal.

Workarounds attempted

  1. skills: "./" (string instead of array): same error, same validation.
  2. Removing the skills field: falls back to default skills/ subdirectory auto-discovery, which is empty for plugins whose source root contains SKILL.md directly.
  3. Adding .claude-plugin/plugin.json with "skills": "./": same error via the plugin.json processing path. Issue #51888 reports this separately.
  4. Restructuring the plugin to use skills/<name>/SKILL.md subdirectory: works, but requires moving SKILL.md. Some projects intentionally place SKILL.md at the source root, for example projects that distribute the same content as a Claude Desktop ZIP and need a single source-of-truth SKILL.md location.

Related issues

  • #51888 (OPEN, 2026-04-22, v2.1.117): Same error message, different code path. #51888 reports plugin.json declaring "skills": "./", which has been rejected since at least v2.1.107. The current report concerns marketplace.json plugin entry with strict: false declaring "skills": ["./"].
  • #53426 (CLOSED 2026-04-29 as duplicate of #13344, reported on v2.1.119): Different problem on the same field. When multiple plugin entries share source: "./" and ship no plugin.json, each plugin's skills: filter is ignored and all skills from the shared skills/ directory load under every namespace. Closed by GitHub Action; #13344 is unrelated and concerns plugin enable/disable behavior.
  • alirezarezvani/claude-skills #539 and PR #587: External marketplace hit by #51888's path (plugin.json). Resolved by restructuring 35 plugins.

Environment

  • Claude Code: v2.1.137 (also reproduced on v2.1.138)
  • Platform: darwin-arm64
  • npm: @anthropic-ai/claude-code@2.1.137 (binary @anthropic-ai/claude-code-darwin-arm64@2.1.137)

Suggested questions for triage

This report does not assert whether the new behavior is intentional or unintentional. Items that may help triage:

  1. Is the v2.1.94-introduced "skills": ["./"] support meant to remain available through marketplace.json plugin entries (with strict: false and no plugin.json)? Or was it always meant to be limited to plugin.json, which has rejected ./ since v2.1.107?
  2. If still supported, the path validation needs an exemption for the case where the resolved path equals the plugin root itself (O === ""). That case is "the path is the plugin root," not "the path escapes the plugin root."
  3. If no longer supported, plugins-reference#path-behavior-rules needs updating to remove the ["./"] example, and a deprecation entry in CHANGELOG would help users find the migration path.

View original on GitHub ↗

This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗