import { realpath } from 'node:fs/promises'; import { isAbsolute, resolve, sep } from 'node:path'; export class PathScopeError extends Error { constructor(message: string) { super(message); this.name = 'PathScopeError'; } } export async function resolveProjectRoot(projectPath: string): Promise { try { return await realpath(projectPath); } catch { throw new PathScopeError(`project path does not exist: ${projectPath}`); } } function isUnder(real: string, root: string): boolean { return real === root || real.startsWith(root + sep); } // v1.13.17-cross-repo-reads: pathGuard now accepts an optional extraRoots // list (typically session.allowed_read_paths). The primary projectRoot is // tried first; if the resolved path doesn't sit under it, each extraRoot is // tried in turn. Throws PathScopeError if no root accepts. The error message // includes a hint pointing the model at the request_read_access tool so it // can self-correct on the next turn — extraRoots IS the persistence // mechanism for those grants, so we only suggest it when there's a missing // grant to ask for (i.e. the path isn't already under any allowed root). export async function pathGuard( projectRoot: string, requested: string, extraRoots: readonly string[] = [], ): Promise { if (typeof requested !== 'string' || requested.length === 0) { throw new PathScopeError('path is required'); } const candidate = isAbsolute(requested) ? requested : resolve(projectRoot, requested); let real: string; try { real = await realpath(candidate); } catch { throw new PathScopeError(`path does not exist: ${requested}`); } if (isUnder(real, projectRoot)) return real; for (const extra of extraRoots) { if (extra.length === 0) continue; if (isUnder(real, extra)) return real; } throw new PathScopeError( `path escapes project root: ${requested} -> ${real}. ` + `Use request_read_access(path, reason) to ask the user for permission.`, ); }