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`;
}