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
- Move targetDir under the allowed clone root (or CLONE_ROOT), e.g. path.join(cloneRoot, repoName).
- Set options.allowedCloneRoot to the intended parent directory of your target.
- Ensure the target is a subdirectory, not the root itself — create a per-repo subdirectory.
- 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
- Never pass raw user input as targetDir; resolve and join it against the clone root.
- Set allowedCloneRoot explicitly in tests and production config.
- Treat any '..' or absolute-path input as a security signal, not a path detail.
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
- path must include owner/repo without traversal
- Clone target must resolve inside
- Clone target parent must resolve inside
- Cloning from private/internal addresses is not allowed
- resolves outside the repository root
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)