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 URL

View on GitHub (pinned to 9690622007)

Solutions

  1. 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".
  2. 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.
  3. 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.
  4. 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

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


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/f0bcd612e0647ebd. Report an issue: GitHub.