abhigyanpatwari/GitNexus · error · Error

Clone target must resolve inside ${root}

Error message

Clone target must resolve inside ${root}

What it means

assertPostRealpathContainment runs after clone/pull and requires the fully resolved target directory to be strictly inside the resolved clone root (empty relative path, '..', or absolute all fail). Unlike the parent-only pre-check, this catches targets that were swapped or symlinked during the operation (TOCTOU) and confirms the final checkout really landed under the root.

Source

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

  return safeTarget;
}

async function assertPreRealpathContainment(root: string, target: string): Promise<void> {
  const realRoot = await fs.realpath(root);
  const realParent = await fs.realpath(path.dirname(target));
  const parentRel = path.relative(realRoot, realParent);
  if (parentRel.startsWith('..') || path.isAbsolute(parentRel)) {
    throw new Error(`Clone target parent must resolve inside ${root}`);
  }
}

async function assertPostRealpathContainment(root: string, target: string): Promise<void> {
  const realRoot = await fs.realpath(root);
  const realTarget = await fs.realpath(target);
  const rel = path.relative(realRoot, realTarget);
  if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
    throw new Error(`Clone target must resolve inside ${root}`);
  }
}

async function assertNoSymlinkPath(
  root: string,
  target: string,
  verifyOwnership = false,
): Promise<void> {
  const resolvedRoot = path.resolve(root);
  const resolvedTarget = path.resolve(target);
  const relativeTarget = path.relative(resolvedRoot, resolvedTarget);
  if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) return;
  let current = resolvedRoot;
  for (const segment of relativeTarget.split(path.sep).filter(Boolean)) {
    current = path.join(current, segment);
    let stat;
    try {
      stat = await fs.lstat(current);

View on GitHub (pinned to 0d1aed942f)

Solutions

  1. Ensure no process replaces or symlinks the target directory during the clone/pull.
  2. Re-check the final path: it must resolve strictly under the clone root; recreate it as a real directory if it is a symlink.
  3. Re-run the operation after removing any symlink at the target path.
  4. Serialize access to the clone root (avoid concurrent clones to the same target).

Example fix

// before
await cloneOrPull({ url, targetDir: maybeSymlinkedPath });
// after
const st = await fs.lstat(maybeSymlinkedPath);
if (st.isSymbolicLink()) throw new Error('remove symlink first');
await cloneOrPull({ url, targetDir: maybeSymlinkedPath });
Defensive patterns

Strategy: validation

Validate before calling

import { fs } from 'node:fs/promises';
const realRoot = await fs.realpath(root);
let realTarget = target;
try { realTarget = await fs.realpath(target); } catch { /* not created yet: OK pre-clone */ }
if (!realTarget.startsWith(realRoot + path.sep)) {
  throw new Error(`target resolves outside clone root`);
}

Try / catch

try {
  await cloneOrPull(opts);
} catch (err) {
  if ((err as Error).message.startsWith('Clone target must resolve inside')) {
    throw new Error(`Target at ${opts.targetDir} was swapped or symlinked during sync; inspect the directory.`, { cause: err });
  }
  throw err;
}

Prevention

When it happens

Trigger: The target itself becomes a symlink during the operation; a race swaps the directory between pre-check and post-check; targetDir resolves to the clone root itself; nested symlink introduced mid-clone.

Common situations: Concurrent processes manipulating the clone tree; malicious or buggy tooling replacing directories during long clones; verification-style setups running the function on paths that are later linked elsewhere.

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@0d1aed942f (2026-09-08). Data as JSON: /api/errors/9cd1057773da3bdc. Report an issue: GitHub.