abhigyanpatwari/GitNexus · error

Clone target must be a subdirectory of

Error message

Clone target must be a subdirectory of ${cloneRoot}

What it means

cloneOrPull throws this when the resolved targetDir is not strictly inside the allowed clone root: path.relative(cloneRoot, safeTarget) is empty (target IS the root), starts with '..' (escapes the root), or is absolute (different tree). This is a path-containment guard preventing clones from being written outside the sanctioned directory.

Solutions

  1. Move targetDir under the allowed clone root (or CLONE_ROOT), e.g. path.join(cloneRoot, repoName).
  2. Set options.allowedCloneRoot to the intended parent directory of your target.
  3. Ensure the target is a subdirectory, not the root itself — create a per-repo subdirectory.
  4. Sanitize user-supplied target paths and resolve them before calling to guarantee containment.

Example fix

// before
await cloneOrPull({ url, targetDir: '/srv/other/location/repo' });
// after
await cloneOrPull({ url, targetDir: '/srv/clones/repo', allowedCloneRoot: '/srv/clones' });
Defensive patterns

Strategy: validation

Validate before calling

import path from 'node:path';
const root = path.resolve(allowedCloneRoot ?? CLONE_ROOT);
const target = path.resolve(userSuppliedTarget);
const rel = path.relative(root, target);
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
  throw new Error(`target must be inside ${root}`);
}

Try / catch

try {
  await cloneOrPull(opts);
} catch (err) {
  if ((err as Error).message.startsWith('Clone target must be a subdirectory')) {
    throw new Error(`Rejected clone destination outside allowed root ${opts.allowedCloneRoot ?? 'default'}`);
  }
  throw err;
}

Prevention

When it happens

Trigger: Passing targetDir outside CLONE_ROOT or outside options.allowedCloneRoot; passing targetDir equal to the clone root itself; passing an absolute path into a different tree; path tricks where the resolved target escapes via '..'.

Common situations: Users configuring a custom clone destination outside the allowed root; tests using tmp dirs without setting allowedCloneRoot; callers joining targetDir from untrusted input that contains '..' segments.

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 abhigyanpatwari/GitNexus@ac9a4e9abd (2026-09-08). Data as JSON: /api/errors/aa151ba35bf6fb4e. Report an issue: GitHub.

Appendix: source

Thrown at gitnexus/src/server/git-clone.ts:538

  // subprocess sink. The same `safeTarget` is used for every downstream
  // path operation — no reassignment that the analyzer could lose track of.
  //
  // The lexical check runs before filesystem creation; realpath and symlink
  // checks below run before pull/clone and again after clone completes.
  const cloneRoot = path.resolve(options?.allowedCloneRoot ?? CLONE_ROOT);
  const expectedRepoName = options?.expectedRepoName;
  if (expectedRepoName !== undefined && expectedRepoName !== extractRepoName(url)) {
    throw new Error(`Clone target repo name ${expectedRepoName} does not match requested URL`);
  }

  const safeTarget = path.resolve(targetDir);
  if (expectedRepoName !== undefined && path.basename(safeTarget) !== expectedRepoName) {
    throw new Error(`Clone target basename must match repository name ${expectedRepoName}`);
  }

  const rel = path.relative(cloneRoot, safeTarget);
  if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
    throw new Error(`Clone target must be a subdirectory of ${cloneRoot}`);
  }

  // Always validate the requested URL — the prior shape only ran this in
  // the code path where the repo was cloned. Now it runs unconditionally,
  // preventing SSRF / blocked-host bypasses even when targetDir already exists.
  if (options?.allowAutoSyncSsh) validateAutoSyncRemoteUrl(url);
  else validateGitUrl(url);
  await fs.mkdir(cloneRoot, { recursive: true });
  if (options?.allowedCloneRoot) {
    await assertDirectoryOwnerAndPermissions(cloneRoot);
  }
  await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot));
  await fs.mkdir(path.dirname(safeTarget), { recursive: true });
  await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot));
  await assertPreRealpathContainment(cloneRoot, safeTarget);

  const exists = await fs.access(path.join(safeTarget, '.git')).then(
    () => true,

View on GitHub (pinned to ac9a4e9abd)