mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
76 lines
3.0 KiB
TypeScript
76 lines
3.0 KiB
TypeScript
import { readdirSync } from "fs";
|
|
import { homedir } from "os";
|
|
import path from "path";
|
|
import { getAdditionalAllowedRoots, normalizeSlashes } from "./allowed-roots";
|
|
import { isExistingPathWithinRoots } from "./path-security";
|
|
import { listAllSessions } from "./session-reader";
|
|
export { allowFileRoot, normalizeSlashes } from "./allowed-roots";
|
|
|
|
// Short-TTL cache for the allowed-roots set. Without this, every file list/read
|
|
// request re-scans every pi session on disk just to check access. 5s is short
|
|
// enough that newly-created cwds appear promptly; stored on globalThis so it
|
|
// survives Next.js hot-reload.
|
|
declare global {
|
|
var __piAllowedRootsCache: { roots: Set<string>; expiresAt: number } | undefined;
|
|
}
|
|
|
|
const ALLOWED_ROOTS_TTL_MS = 5_000;
|
|
const WINDOWS_ABSOLUTE_RE = /^[a-zA-Z]:[\\/]/;
|
|
|
|
export function isWindowsAbsolutePath(filePath: string): boolean {
|
|
return WINDOWS_ABSOLUTE_RE.test(filePath) || filePath.startsWith("\\\\") || filePath.startsWith("//");
|
|
}
|
|
|
|
export async function getAllowedFileRoots(): Promise<Set<string>> {
|
|
const now = Date.now();
|
|
const cached = globalThis.__piAllowedRootsCache;
|
|
if (cached && cached.expiresAt > now) return cached.roots;
|
|
|
|
const sessions = await listAllSessions();
|
|
const roots = new Set<string>();
|
|
for (const s of sessions) {
|
|
if (s.cwd) roots.add(normalizeSlashes(s.cwd));
|
|
// The project root (main repo shared by all worktrees) is browsable too —
|
|
// the project dropdown lists it even when only worktrees have sessions.
|
|
if (s.projectRoot) roots.add(normalizeSlashes(s.projectRoot));
|
|
}
|
|
|
|
// Also allow ~/pi-cwd-* directories created by the default-cwd endpoint.
|
|
try {
|
|
for (const name of readdirSync(homedir())) {
|
|
if (/^pi-cwd-\d{8}$/.test(name)) {
|
|
roots.add(normalizeSlashes(path.join(homedir(), name)));
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore if home is unreadable
|
|
}
|
|
|
|
for (const root of getAdditionalAllowedRoots()) roots.add(root);
|
|
|
|
globalThis.__piAllowedRootsCache = { roots, expiresAt: now + ALLOWED_ROOTS_TTL_MS };
|
|
return roots;
|
|
}
|
|
|
|
export function isFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
|
|
for (const root of allowedRoots) {
|
|
const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
|
|
const resolver = useWindowsRules ? path.win32 : path;
|
|
const sep = useWindowsRules ? "\\" : path.sep;
|
|
const normalized = resolver.resolve(target);
|
|
const normalizedRoot = resolver.resolve(root);
|
|
const comparable = useWindowsRules ? normalized.toLowerCase() : normalized;
|
|
const comparableRoot = useWindowsRules ? normalizedRoot.toLowerCase() : normalizedRoot;
|
|
const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
|
|
if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Authorize an existing path after resolving symbolic links. */
|
|
export function isExistingFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
|
|
return isExistingPathWithinRoots(target, allowedRoots);
|
|
}
|