can1357/oh-my-pi · error · Error
Plugin source "${source}" resolves outside marketplace root
Error message
Plugin source "${source}" resolves outside marketplace root ("${context.marketplaceClonePath}") What it means
This error is thrown by resolveRelativeSource when a marketplace plugin entry uses a relative string source (e.g. "./plugins/foo") that, after path resolution against the marketplace clone root, escapes that root directory. The resolver resolves the source with path.resolve(context.marketplaceClonePath, relativePath) (optionally prepending the catalog's pluginRoot) and then enforces a containment check with pathIsWithin before touching disk. It is a security guard: a catalog entry must never point at directories outside the marketplace checkout, so any path that traverses above the root ("../", absolute-looking joins, or pluginRoot+source combinations that cancel out) is rejected rather than loaded.
Source
Thrown at packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts:71
context: ResolveContext,
): Promise<{ dir: string; tempCloneRoot?: string }> {
if (!source.startsWith("./")) {
throw new Error(`Relative plugin source paths must start with "./" — got: "${source}"`);
}
if (!context.marketplaceClonePath) {
throw new Error(`Cannot resolve relative source "${source}": marketplaceClonePath is required`);
}
// If pluginRoot is set, prepend it to the path segment after "./"
const pluginRoot = context.catalogMetadata?.pluginRoot;
const relativePath = pluginRoot ? `./${path.join(pluginRoot, source.slice(2))}` : source;
// Resolve against marketplace root (not the .claude-plugin/ catalog subdirectory)
const resolved = path.resolve(context.marketplaceClonePath, relativePath);
if (!pathIsWithin(context.marketplaceClonePath, resolved)) {
throw new Error(
`Plugin source "${source}" resolves outside marketplace root ("${context.marketplaceClonePath}")`,
);
}
await verifyDirExists(resolved, `Plugin source directory does not exist: "${resolved}"`);
return { dir: resolved };
}
// ── Object source variants ──────────────────────────────────────────
async function resolveObjectSource(
source: Exclude<PluginSource, string>,
context: ResolveContext,
): Promise<{ dir: string; tempCloneRoot?: string }> {
switch (source.source) {
case "url": {
// { source: "url", url: "https://github.com/owner/repo.git" }
// Despite the name, this is typically a git clone URLView on GitHub (pinned to 9690622007)
Solutions
- Fix the relative source in the marketplace catalog (.claude-plugin/marketplace.json) so it stays inside the marketplace root — remove excess ".." segments, e.g. change "../../shared/foo" to the correct "./plugins/foo".
- If catalogMetadata.pluginRoot is set, verify it is a plain repo-relative directory (e.g. "plugins") and that pluginRoot + the entry's relative path resolves inside the clone; correct pluginRoot in the catalog metadata.
- Verify the marketplace clone is intact and at the expected layout (the referenced directory actually exists under the clone root); re-clone or update the marketplace if it was pruned or restructured.
- If you authored the catalog, add the referenced directory inside the marketplace repo instead of referencing something outside it.
Example fix
// before (.claude-plugin/marketplace.json)
{ "name": "shared-utils", "source": "../../shared-utils" }
// after
{ "name": "shared-utils", "source": "./plugins/shared-utils" } Defensive patterns
Strategy: validation
Validate before calling
import * as path from "node:path";
function relativeSourceStaysInRoot(source: string, root: string, pluginRoot?: string): boolean {
if (!source.startsWith("./")) return false; // resolver requires "./" prefix
const joined = pluginRoot ? `./${path.join(pluginRoot, source.slice(2))}` : source;
const resolved = path.resolve(root, joined);
const rel = path.relative(root, resolved);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
} Type guard
function isRelativePluginSource(source: unknown): source is string {
return typeof source === "string" && source.startsWith("./");
} Try / catch
try {
const { dir } = await resolvePluginSource(entry, context);
} catch (err) {
if (err instanceof Error && err.message.includes("resolves outside marketplace root")) {
// reject the catalog entry; log source + marketplaceClonePath, skip plugin
} else throw err;
} Prevention
- Keep all relative plugin sources as "./<dir-inside-clone>" with no ".." segments
- Validate marketplace.json entries at load time with a schema that rejects ".." in relative sources
- Keep pluginRoot a plain repo-relative directory and test pluginRoot+source joins resolve inside the clone
- Treat traversal attempts as a security signal — audit the marketplace source if this fires
When it happens
Trigger: resolvePluginSource is called with an entry whose source is a string (so it goes through resolveRelativeSource), and path.resolve(marketplaceClonePath, relativePath) yields a path that is not within marketplaceClonePath. Concretely: (1) the catalog JSON contains a relative source with upward traversal like "../../etc" or "./../../somewhere"; (2) catalogMetadata.pluginRoot is itself an absolute or escaping path such that path.join(pluginRoot, source.slice(2)) combined with source escapes the root; (3) a hand-edited .claude-plugin/marketplace.json entry has a typo like "./../shared-plugin" that climbs out of the clone.
Common situations: Hand-editing a marketplace.json and mistyping a relative path with too many ".." segments; copying a plugin entry from another marketplace whose layout assumed a different pluginRoot; a malicious or compromised marketplace catalog attempting path traversal to load code from arbitrary filesystem locations; moving the marketplace clone to a shallower directory so formerly-internal relative paths now resolve outside it.
Understand the failure class
Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.
Related errors
- Plugin "${entry.name}" lspServers path escapes the plugin di
- Plugin "${entry.name}" dapAdapters path escapes the plugin d
- git-subdir path "${source.path}" escapes the cloned reposito
- Destination paths cannot contain parent traversal or NUL byt
- Shared-folder destination escapes its configured root
AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31).
Data as JSON: /api/errors/f0bcd612e0647ebd.
Report an issue: GitHub.