Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/git-safe/clone.ts

Raw
// 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<void> {
	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<SafeCloneResult> {
	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: <mode> <type> <hash> <size>\t<path>
			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`;
}