{"record":{"id":"f0bcd612e0647ebd","repo":"can1357/oh-my-pi","slug":"plugin-source-source-resolves-outside-marketp","errorCode":null,"errorMessage":"Plugin source \"${source}\" resolves outside marketplace root (\"${context.marketplaceClonePath}\")","messagePattern":"Plugin source \"(.+?)\" resolves outside marketplace root \\(\"(.+?)\"\\)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts","lineNumber":71,"sourceCode":"\tcontext: ResolveContext,\n): Promise<{ dir: string; tempCloneRoot?: string }> {\n\tif (!source.startsWith(\"./\")) {\n\t\tthrow new Error(`Relative plugin source paths must start with \"./\" — got: \"${source}\"`);\n\t}\n\n\tif (!context.marketplaceClonePath) {\n\t\tthrow new Error(`Cannot resolve relative source \"${source}\": marketplaceClonePath is required`);\n\t}\n\n\t// If pluginRoot is set, prepend it to the path segment after \"./\"\n\tconst pluginRoot = context.catalogMetadata?.pluginRoot;\n\tconst relativePath = pluginRoot ? `./${path.join(pluginRoot, source.slice(2))}` : source;\n\n\t// Resolve against marketplace root (not the .claude-plugin/ catalog subdirectory)\n\tconst resolved = path.resolve(context.marketplaceClonePath, relativePath);\n\n\tif (!pathIsWithin(context.marketplaceClonePath, resolved)) {\n\t\tthrow new Error(\n\t\t\t`Plugin source \"${source}\" resolves outside marketplace root (\"${context.marketplaceClonePath}\")`,\n\t\t);\n\t}\n\n\tawait verifyDirExists(resolved, `Plugin source directory does not exist: \"${resolved}\"`);\n\treturn { dir: resolved };\n}\n\n// ── Object source variants ──────────────────────────────────────────\n\nasync function resolveObjectSource(\n\tsource: Exclude<PluginSource, string>,\n\tcontext: ResolveContext,\n): Promise<{ dir: string; tempCloneRoot?: string }> {\n\tswitch (source.source) {\n\t\tcase \"url\": {\n\t\t\t// { source: \"url\", url: \"https://github.com/owner/repo.git\" }\n\t\t\t// Despite the name, this is typically a git clone URL","sourceCodeStart":53,"sourceCodeEnd":89,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts#L53-L89","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before (.claude-plugin/marketplace.json)\n{ \"name\": \"shared-utils\", \"source\": \"../../shared-utils\" }\n\n// after\n{ \"name\": \"shared-utils\", \"source\": \"./plugins/shared-utils\" }","handlingStrategy":"validation","validationCode":"import * as path from \"node:path\";\n\nfunction relativeSourceStaysInRoot(source: string, root: string, pluginRoot?: string): boolean {\n  if (!source.startsWith(\"./\")) return false; // resolver requires \"./\" prefix\n  const joined = pluginRoot ? `./${path.join(pluginRoot, source.slice(2))}` : source;\n  const resolved = path.resolve(root, joined);\n  const rel = path.relative(root, resolved);\n  return rel !== \"\" && !rel.startsWith(\"..\") && !path.isAbsolute(rel);\n}","typeGuard":"function isRelativePluginSource(source: unknown): source is string {\n  return typeof source === \"string\" && source.startsWith(\"./\");\n}","tryCatchPattern":"try {\n  const { dir } = await resolvePluginSource(entry, context);\n} catch (err) {\n  if (err instanceof Error && err.message.includes(\"resolves outside marketplace root\")) {\n    // reject the catalog entry; log source + marketplaceClonePath, skip plugin\n  } else throw err;\n}","preventionTips":["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"],"tags":["path-traversal","security","plugin-marketplace","configuration"],"backgroundTag":"path-traversal-blocked","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}