src / pathGuard.ts
import * as path from "node:path";
import * as fs from "node:fs/promises";
import * as os from "node:os";
export class PathError extends Error {
constructor(message: string) {
super(message);
this.name = "PathError";
}
}
export function expandTilde(p: string): string {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return p;
}
export async function canonicalizeRoots(rawRoots: string[]): Promise<string[]> {
const out: string[] = [];
for (const raw of rawRoots) {
if (!raw.trim()) continue;
const expanded = path.resolve(expandTilde(raw.trim()));
try {
out.push(await fs.realpath(expanded));
} catch {
out.push(expanded);
}
}
return out;
}
export async function resolveSafe(
rawPath: string,
allowedRoots: string[],
): Promise<{ abs: string; root: string }> {
if (!allowedRoots.length) {
throw new PathError(
"No allowed paths are configured. Set the `Allowed root paths` field in the plugin's per-chat config.",
);
}
if (!rawPath || typeof rawPath !== "string") {
throw new PathError("Path is required.");
}
const expanded = expandTilde(rawPath);
let candidate: string;
if (path.isAbsolute(expanded)) {
candidate = expanded;
} else {
const firstSegment = expanded.split(path.sep)[0];
const matchingRoot = allowedRoots.find((r) => path.basename(r) === firstSegment);
if (matchingRoot) {
const rest = expanded.split(path.sep).slice(1).join(path.sep);
candidate = path.resolve(matchingRoot, rest);
} else {
candidate = path.resolve(allowedRoots[0], expanded);
}
}
const normalized = path.resolve(candidate);
let resolved = normalized;
try {
resolved = await fs.realpath(normalized);
} catch {
const dir = path.dirname(normalized);
const base = path.basename(normalized);
try {
resolved = path.join(await fs.realpath(dir), base);
} catch {
resolved = normalized;
}
}
for (const root of allowedRoots) {
if (resolved === root || resolved.startsWith(root + path.sep)) {
return { abs: resolved, root };
}
}
throw new PathError(
`Path "${rawPath}" resolves to "${resolved}", which is outside the allowed roots: ${allowedRoots.join(", ")}.`,
);
}
export const BACKUP_DIR_NAME = ".lmstudio-fs-backup";
export function isInsideBackupDir(absPath: string, root: string): boolean {
const backupRoot = path.join(root, BACKUP_DIR_NAME);
return absPath === backupRoot || absPath.startsWith(backupRoot + path.sep);
}