{"record":{"id":"4b702e76360f7186","repo":"can1357/oh-my-pi","slug":"git-subdir-path-source-path-escapes-the-clone","errorCode":null,"errorMessage":"git-subdir path \"${source.path}\" escapes the cloned repository","messagePattern":"git-subdir path \"(.+?)\" escapes the cloned repository","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts","lineNumber":127,"sourceCode":"\t\t}\n\n\t\tcase \"git-subdir\": {\n\t\t\t// { source: \"git-subdir\", url: \"owner/repo\" | \"https://...\", path: \"plugins/foo\" }\n\t\t\tconst url =\n\t\t\t\tsource.url.includes(\"://\") || source.url.startsWith(\"git@\")\n\t\t\t\t\t? source.url\n\t\t\t\t\t: `https://github.com/${source.url}.git`;\n\t\t\tconst cloneDir = path.join(context.tmpDir, `plugin-repo-${crypto.randomUUID()}`);\n\t\t\tawait vcs.clone(url, cloneDir, {\n\t\t\t\trefName: source.ref,\n\t\t\t\tsha: source.sha,\n\t\t\t\ttimeoutMs: GIT_CLONE_TIMEOUT_MS,\n\t\t\t});\n\n\t\t\tconst subdirPath = path.resolve(cloneDir, source.path);\n\t\t\tif (!pathIsWithin(cloneDir, subdirPath)) {\n\t\t\t\tawait fs.rm(cloneDir, { recursive: true, force: true });\n\t\t\t\tthrow new Error(`git-subdir path \"${source.path}\" escapes the cloned repository`);\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tawait verifyDirExists(subdirPath, `git-subdir path \"${source.path}\" does not exist in cloned repository`);\n\t\t\t} catch (err) {\n\t\t\t\tawait fs.rm(cloneDir, { recursive: true, force: true });\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t\treturn { dir: subdirPath, tempCloneRoot: cloneDir };\n\t\t}\n\n\t\tcase \"npm\":\n\t\t\tthrow new Error(\"npm plugin sources are not yet supported. Use git-based sources instead.\");\n\n\t\tdefault:\n\t\t\tthrow new Error(`Unknown plugin source type: \"${(source as { source: string }).source}\"`);\n\t}\n}\n","sourceCodeStart":109,"sourceCodeEnd":145,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts#L109-L145","documentation":"Thrown by resolveObjectSource for a git-subdir plugin source: after cloning the repository into a temp directory, the resolver resolves source.path against the clone root and rejects it if the result is not contained within the clone. Before throwing, it cleans up the temp clone (fs.rm recursive/force) so no partial clone is left behind. This is a containment guard against path traversal — a catalog entry must not be able to pull a plugin directory from outside the repository it declares.","triggerScenarios":"resolvePluginSource is called with an object source of the form { source: \"git-subdir\", url: ..., path: ... } and path.resolve(cloneDir, source.path) lands outside the freshly cloned repository: (1) path contains \"..\" segments that climb out of the clone, e.g. \"../../other-plugin\"; (2) path is an absolute path like \"/opt/plugins/foo\" (path.resolve discards cloneDir entirely); (3) the ref/sha that was cloned moved or a symlink inside the repo redirects outside (symlinked path components resolving elsewhere).","commonSituations":"A marketplace catalog entry copied from another repo with a path written relative to a different layout; typos like \"../plugins/foo\" in the git-subdir path; a compromised or malicious marketplace publishing traversal paths to exfiltrate/load arbitrary directories; using an absolute path because the author misunderstood that path is clone-relative.","solutions":["Edit the catalog entry so source.path is a repository-relative directory that stays inside the clone, e.g. { \"source\": \"git-subdir\", \"url\": \"owner/repo\", \"path\": \"plugins/foo\" } — remove \"..\" segments and any leading \"/\".","Confirm the subdirectory actually exists at the ref/sha being cloned; fix ref/sha if the layout changed between versions.","Check the cloned repo for symlinks whose targets live outside the repository and replace or remove them.","If the plugin genuinely lives in a different repository, point the source at that repo directly (source \"url\" or \"github\") instead of trying to escape via path."],"exampleFix":"// before (marketplace.json entry)\n{ \"source\": \"git-subdir\", \"url\": \"owner/monorepo\", \"path\": \"../shared-plugin\" }\n\n// after\n{ \"source\": \"git-subdir\", \"url\": \"owner/monorepo\", \"path\": \"packages/shared-plugin\" }","handlingStrategy":"validation","validationCode":"import * as path from \"node:path\";\n\nfunction gitSubdirPathIsSafe(p: string): boolean {\n  return typeof p === \"string\" && p.length > 0 && !path.isAbsolute(p)\n    && p.split(/[\\\\/]/).every(seg => seg !== \"..\");\n}\n// run before calling resolvePluginSource:\n// if (source.source === \"git-subdir\" && !gitSubdirPathIsSafe(source.path)) reject(entry);","typeGuard":"function isGitSubdirSource(source: unknown): source is { source: \"git-subdir\"; url: string; path: string; ref?: string; sha?: string } {\n  return typeof source === \"object\" && source !== null\n    && (source as { source?: unknown }).source === \"git-subdir\"\n    && typeof (source as { url?: unknown }).url === \"string\"\n    && typeof (source as { path?: unknown }).path === \"string\";\n}","tryCatchPattern":"try {\n  const { dir, tempCloneRoot } = await resolvePluginSource(entry, context);\n} catch (err) {\n  if (err instanceof Error && err.message.includes(\"escapes the cloned repository\")) {\n    // fix or reject the catalog entry; the temp clone was already cleaned up by the resolver\n  } else throw err;\n}","preventionTips":["Write git-subdir paths as clone-relative with no \"..\" segments and no leading \"/\"","Pin ref/sha and confirm the subdir exists at that revision before publishing the catalog","Validate catalog entries with a schema rejecting traversal segments in git-subdir paths","Avoid symlinks in plugin repos that point outside the repository"],"tags":["path-traversal","security","git","plugin-marketplace"],"backgroundTag":"path-traversal-blocked","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}