// clone.ts — Safe git clone with defense-in-depth against: // - Post-checkout hooks (RCE) // - Symlink traversal // - LFS smudge filters (RCE) // - Disk exhaustion // - Credential prompt hangs import { existsSync, realpathSync } from "node:fs"; import { lstat, mkdir, readdir, readFile, rm, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, join, normalize, parse, relative, resolve, sep, } from "node:path"; import { isMarkerPresent, MARKER_FILE } from "./marker.js"; import { SpawnExitError, SpawnTimeoutError, spawnExpect } from "./spawn.js"; // ── Constants ────────────────────────────────────────────────────────────── const CHECKOUT_TIMEOUT_MS = 30_000; // 30s // ── Types ────────────────────────────────────────────────────────────────── export interface SafeCloneLimits { sizeLimitBytes: number; fileCountLimit: number; cloneTimeoutMs: number; } export interface SafeCloneResult { path: string; // canonical (realpath) absolute path url: string; branch?: string; fileCount: number; totalSizeBytes: number; symlinkCount: number; // demoted symlinks (stored as text files) symlinks: string[]; // paths of demoted symlinks warnings: string[]; sparse?: string[]; } export interface SafeCloneError { message: string; // The actionable error for the LLM cause: string; // Root cause detail nextStep: string; // What to do alternative?: string; // Plan B dont: string; // "DO NOT ..." } // ── Path safety ──────────────────────────────────────────────────────────── /** Reject obviously dangerous paths */ export function isDangerousPath(p: string): string | null { const resolved = resolve(p); const home = homedir(); const root = parse(resolved).root; const parts = relative(root, resolved).split(sep).filter(Boolean); // Root if (resolved === root) return "path is root directory"; // Home dir itself if (resolved === home) return "path is home directory"; // Too shallow (depth < 2) if (parts.length < 2) return `path is too shallow (${resolved})`; // Common dangerous locations if (resolved === "/tmp") return "path is /tmp"; if (resolved === "/var") return "path is /var"; if (resolved === "/etc") return "path is /etc"; if (resolved === "/usr") return "path is /usr"; if (resolved.startsWith("/sys") || resolved.startsWith("/proc")) return "path is a virtual filesystem"; return null; } // ── Marker file ──────────────────────────────────────────────────────────── async function writeMarker( clonePath: string, url: string, meta: { branch?: string; sparse?: string[] } = {}, ): Promise { const markerContent = JSON.stringify( { url, branch: meta.branch, sparse: meta.sparse, createdAt: new Date().toISOString(), version: 2, }, null, 2, ); await writeFile(join(clonePath, MARKER_FILE), markerContent, "utf-8"); } // ── Error formatting ─────────────────────────────────────────────────────── function formatError( what: string, cause: string, nextStep: string, dont: string, alternative?: string, ): string { let msg = `git_clone_safe failed: ${what}\n Root cause: ${cause}`; msg += `\n → ${nextStep}`; if (alternative) msg += `\n → ${alternative}`; msg += `\n ⛔ DO NOT ${dont}`; return msg; } // ── Main clone logic ────────────────────────────────────────────────────── export function validateSparsePath(path: string): void { const normalized = normalize(path).replaceAll("\\", "/"); if ( !path || isAbsolute(path) || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../") || path.includes("\0") ) { throw new Error(`unsafe sparse path: ${path}`); } } export function buildCloneArgs( url: string, absPath: string, opts: { branch?: string; sparse?: string[] }, ): string[] { const cloneArgs = [ "clone", "--no-checkout", "--depth", "1", "--single-branch", ]; if (opts.sparse?.length) cloneArgs.push("--filter=blob:none", "--sparse"); cloneArgs.push("-c", "core.symlinks=false"); if (opts.branch) cloneArgs.push("--branch", opts.branch); cloneArgs.push(url, absPath); return cloneArgs; } export async function safeClone( url: string, path: string, opts: { branch?: string; sparse?: string[]; cwd: string; limits: SafeCloneLimits; }, ): Promise { const { sizeLimitBytes, fileCountLimit, cloneTimeoutMs } = opts.limits; const absPath = resolve(opts.cwd, path); const warnings: string[] = []; // ── Pre-flight: reject dangerous paths ───────────────────────── const danger = isDangerousPath(absPath); if (danger) { throw new Error( formatError( `refusing to clone into unsafe path: ${absPath}`, danger, "Choose a deeper subdirectory, e.g. ./repos/owner/project", "bypass this safety check or use raw git clone.", ), ); } // ── Pre-flight: handle existing directory ────────────────────── if (existsSync(absPath)) { if (isMarkerPresent(absPath)) { // Our prior clone — safe to replace await rm(absPath, { recursive: true, force: true }); } else { throw new Error( formatError( `target directory already exists: ${absPath}`, "Directory exists and was not created by git_clone_safe (no .pi-git-safe marker file).", "Remove the directory manually first, or choose a different path.", "use raw git clone to bypass this safety check.", "If you want to replace it, delete it first: rm -rf " + absPath, ), ); } } const sparse = opts.sparse?.filter((p) => p.trim().length > 0); for (const p of sparse ?? []) validateSparsePath(p); // ── Step 1: Clone with no checkout ───────────────────────────── const cloneArgs = buildCloneArgs(url, absPath, { branch: opts.branch, sparse, }); try { await spawnExpect("git", cloneArgs, { timeout: cloneTimeoutMs }); } catch (e) { // Clean up partial clone if (existsSync(absPath)) await rm(absPath, { recursive: true, force: true }).catch(() => {}); if (e instanceof SpawnExitError) { throw new Error( formatError( "git clone returned non-zero exit code", e.stderr.trim().split("\n").pop() || `exit code ${e.exitCode}`, "Verify the URL is correct and the repository is accessible. Check for typos in owner/repo.", "retry with raw git clone — it bypasses safety protections.", "If the repo is private, ask the user to configure SSH keys or provide a personal access token.", ), ); } if (e instanceof SpawnTimeoutError) { throw new Error( formatError( "git clone timed out", `Clone did not complete within ${cloneTimeoutMs / 1000}s. The repository may be very large or the server is unreachable.`, "Check your network connection and the repository size. Try a specific branch with shallow history.", "use raw git clone to bypass this safety check.", ), ); } throw e; } // ── Step 2: Configure safety (repo-local) ────────────────────── const emptyHooksDir = join(absPath, ".git", "pi-safe-hooks"); try { await mkdir(emptyHooksDir, { recursive: true }); await spawnExpect("git", ["config", "core.hooksPath", emptyHooksDir], { cwd: absPath, }); await spawnExpect("git", ["config", "lfs.skipSmudge", "true"], { cwd: absPath, }); if (sparse?.length) { await spawnExpect("git", ["sparse-checkout", "set", "--", ...sparse], { cwd: absPath, }); } // Disable all filter drivers (LFS, custom filters) await writeFile( join(absPath, ".git", "info", "attributes"), "* -filter\n", "utf-8", ); // Belt-and-suspenders: remove default hooks directory await rm(join(absPath, ".git", "hooks"), { recursive: true, force: true, }).catch(() => {}); } catch (e) { await rm(absPath, { recursive: true, force: true }).catch(() => {}); throw new Error( formatError( "failed to configure safety settings in cloned repository", (e as Error).message, "This may be a filesystem permissions issue. Check write access to the target path.", "use raw git clone — the safety configuration is essential.", ), ); } // ── Step 3: Pre-checkout size validation from metadata ────────── let totalSize = 0; let fileCount = 0; try { const lsTreeArgs = ["ls-tree", "-r", "-l", "HEAD"]; if (sparse?.length) lsTreeArgs.push("--", ...sparse); const lsTree = await spawnExpect("git", lsTreeArgs, { cwd: absPath, }); for (const line of lsTree.trim().split("\n")) { if (!line.trim()) continue; // Format: \t const sizeMatch = line.match(/\s+(\d+)\t/); if (sizeMatch) { totalSize += parseInt(sizeMatch[1], 10); fileCount++; } } } catch (_e) { // ls-tree may fail on empty repos — that's fine warnings.push( "Could not compute pre-checkout size (empty repo or ls-tree unavailable).", ); } if (totalSize > sizeLimitBytes) { await rm(absPath, { recursive: true, force: true }).catch(() => {}); throw new Error( formatError( `repository content exceeds safety limit (${formatSize(totalSize)} > ${formatSize(sizeLimitBytes)})`, "Tree metadata indicates the total file content exceeds the configured size limit.", "Use sparse checkout to clone specific directories only, or inspect the repository manually in a browser.", "increase the size limit or bypass this check — disk exhaustion risk.", ), ); } if (fileCount > fileCountLimit) { await rm(absPath, { recursive: true, force: true }).catch(() => {}); throw new Error( formatError( `repository has too many files (${fileCount} > ${fileCountLimit})`, "Shallow clone with this many files risks excessive disk and memory usage.", "Use sparse checkout to clone specific directories only.", "bypass this file count limit.", ), ); } // ── Step 4: Pre-checkout symlink scan ────────────────────────── let symlinkCount = 0; const symlinks: string[] = []; try { const lsFilesArgs = ["ls-files", "-s"]; if (sparse?.length) lsFilesArgs.push("--", ...sparse); const lsFiles = await spawnExpect("git", lsFilesArgs, { cwd: absPath, }); for (const line of lsFiles.trim().split("\n")) { // Mode 120000 = symlink if (line.startsWith("120000 ")) { symlinkCount++; const pathMatch = line.match(/^\S+\s+\S+\s+\S+\s+(.+)$/); if (pathMatch) symlinks.push(pathMatch[1]); } } } catch { // Best effort } if (symlinkCount > 0) { warnings.push( `Repository contains ${symlinkCount} symlink(s). ` + "core.symlinks=false ensures they are stored as text files, not followed. " + "Symlink targets: " + symlinks.slice(0, 5).join(", ") + (symlinkCount > 5 ? ` (and ${symlinkCount - 5} more)` : ""), ); } // ── Step 5: Checkout with timeout ────────────────────────────── try { await spawnExpect( "git", ["-c", `core.hooksPath=${emptyHooksDir}`, "checkout"], { cwd: absPath, timeout: CHECKOUT_TIMEOUT_MS }, ); } catch (e) { await rm(absPath, { recursive: true, force: true }).catch(() => {}); if (e instanceof SpawnTimeoutError) { throw new Error( formatError( "git checkout timed out", `Checkout did not complete within ${CHECKOUT_TIMEOUT_MS / 1000}s. The repository may have too many/large files for on-demand fetching, or the server is slow.`, "Try a more specific branch or sparse checkout path. If the repo is large, clone specific directories.", "retry without changes or use raw git clone — safety controls are essential.", ), ); } if (e instanceof SpawnExitError) { throw new Error( formatError( "git checkout failed", e.stderr.trim().split("\n").pop() || `exit code ${e.exitCode}`, "The checkout step failed after clone succeeded. This may be a corrupt repository or filesystem issue.", "use raw git clone to bypass this check.", ), ); } throw e; } const materialized = await scanWorkingTree(absPath); fileCount = materialized.fileCount; totalSize = materialized.totalSizeBytes; if (fileCount > 0) { const noise = "Could not compute pre-checkout size (empty repo or ls-tree unavailable)."; const index = warnings.indexOf(noise); if (index >= 0) warnings.splice(index, 1); } if (totalSize > sizeLimitBytes) { await rm(absPath, { recursive: true, force: true }).catch(() => {}); throw new Error( formatError( `checkout content exceeds safety limit (${formatSize(totalSize)} > ${formatSize(sizeLimitBytes)})`, "Working tree materialized more data than allowed.", "Use a narrower sparse checkout.", "bypass this post-checkout size check.", ), ); } if (fileCount > fileCountLimit) { await rm(absPath, { recursive: true, force: true }).catch(() => {}); throw new Error( formatError( `checkout has too many files (${fileCount} > ${fileCountLimit})`, "Working tree materialized more files than allowed.", "Use a narrower sparse checkout.", "bypass this post-checkout file count check.", ), ); } // ── Step 6: Post-checkout verification ───────────────────────── // Audit .git/config for suspicious include directives try { const gitConfig = await readFile(join(absPath, ".git", "config"), "utf-8"); if (/\binclude(?:If)?\s*=/i.test(gitConfig)) { warnings.push( ".git/config contains include/includeIf directives. " + "These could reference paths outside the clone. " + "Review: " + gitConfig .split("\n") .filter((l) => /\binclude/i.test(l)) .join(", "), ); } } catch { // Best effort } // Write marker file await writeMarker(absPath, url, { branch: opts.branch, sparse }); // Canonical path const canonicalPath = realpathSync(absPath); return { path: canonicalPath, url, branch: opts.branch, fileCount, totalSizeBytes: totalSize, symlinkCount, symlinks, warnings, sparse, }; } // ── Helpers ──────────────────────────────────────────────────────────────── async function scanWorkingTree(root: string): Promise<{ fileCount: number; totalSizeBytes: number; }> { let fileCount = 0; let totalSizeBytes = 0; const stack = [root]; while (stack.length > 0) { const dir = stack.pop(); if (!dir) continue; for (const entry of await readdir(dir, { withFileTypes: true })) { if (dir === root && entry.name === ".git") continue; const p = join(dir, entry.name); if (entry.isDirectory()) { stack.push(p); continue; } const s = await lstat(p); if (!s.isFile()) continue; fileCount++; totalSizeBytes += s.size; } } return { fileCount, totalSizeBytes }; } function formatSize(bytes: number): string { if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)}GB`; if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)}MB`; if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)}KB`; return `${bytes}B`; }