can1357/oh-my-pi · error · Error

git-subdir path "${source.path}" escapes the cloned reposito

Error message

git-subdir path "${source.path}" escapes the cloned repository

What it means

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.

Source

Thrown at packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts:127

		}

		case "git-subdir": {
			// { source: "git-subdir", url: "owner/repo" | "https://...", path: "plugins/foo" }
			const url =
				source.url.includes("://") || source.url.startsWith("git@")
					? source.url
					: `https://github.com/${source.url}.git`;
			const cloneDir = path.join(context.tmpDir, `plugin-repo-${crypto.randomUUID()}`);
			await vcs.clone(url, cloneDir, {
				refName: source.ref,
				sha: source.sha,
				timeoutMs: GIT_CLONE_TIMEOUT_MS,
			});

			const subdirPath = path.resolve(cloneDir, source.path);
			if (!pathIsWithin(cloneDir, subdirPath)) {
				await fs.rm(cloneDir, { recursive: true, force: true });
				throw new Error(`git-subdir path "${source.path}" escapes the cloned repository`);
			}
			try {
				await verifyDirExists(subdirPath, `git-subdir path "${source.path}" does not exist in cloned repository`);
			} catch (err) {
				await fs.rm(cloneDir, { recursive: true, force: true });
				throw err;
			}
			return { dir: subdirPath, tempCloneRoot: cloneDir };
		}

		case "npm":
			throw new Error("npm plugin sources are not yet supported. Use git-based sources instead.");

		default:
			throw new Error(`Unknown plugin source type: "${(source as { source: string }).source}"`);
	}
}

View on GitHub (pinned to 9690622007)

Solutions

  1. 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 "/".
  2. Confirm the subdirectory actually exists at the ref/sha being cloned; fix ref/sha if the layout changed between versions.
  3. Check the cloned repo for symlinks whose targets live outside the repository and replace or remove them.
  4. 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.

Example fix

// before (marketplace.json entry)
{ "source": "git-subdir", "url": "owner/monorepo", "path": "../shared-plugin" }

// after
{ "source": "git-subdir", "url": "owner/monorepo", "path": "packages/shared-plugin" }
Defensive patterns

Strategy: validation

Validate before calling

import * as path from "node:path";

function gitSubdirPathIsSafe(p: string): boolean {
  return typeof p === "string" && p.length > 0 && !path.isAbsolute(p)
    && p.split(/[\\/]/).every(seg => seg !== "..");
}
// run before calling resolvePluginSource:
// if (source.source === "git-subdir" && !gitSubdirPathIsSafe(source.path)) reject(entry);

Type guard

function isGitSubdirSource(source: unknown): source is { source: "git-subdir"; url: string; path: string; ref?: string; sha?: string } {
  return typeof source === "object" && source !== null
    && (source as { source?: unknown }).source === "git-subdir"
    && typeof (source as { url?: unknown }).url === "string"
    && typeof (source as { path?: unknown }).path === "string";
}

Try / catch

try {
  const { dir, tempCloneRoot } = await resolvePluginSource(entry, context);
} catch (err) {
  if (err instanceof Error && err.message.includes("escapes the cloned repository")) {
    // fix or reject the catalog entry; the temp clone was already cleaned up by the resolver
  } else throw err;
}

Prevention

When it happens

Trigger: 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).

Common situations: 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.

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/4b702e76360f7186. Report an issue: GitHub.