// v1.13.17-cross-repo-reads: derives the grant root for a path the user is // being asked to approve cross-repo read access to. // // Per design decision D1: grant unit = nearest registered project root, // then nearest path-whitelist ancestor that looks like a repo root, then // refuse. Granting the literal file path is too narrow (next file in the // same repo re-prompts). Granting an arbitrary parent dir over-scopes. // // The resolver runs in two contexts: // 1. request_read_access.execute — pre-prompt validation (cheap; bails // early if the path can't plausibly be granted so the user is never // asked about /etc/passwd) // 2. POST /api/chats/:id/grant_read_access — at decision time, re-derives // the root and persists it on sessions.allowed_read_paths // // Sam (2026-05-22 dispatch confirmation): "in the project-root resolver // ancestor walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits // filesystem root — check on every iteration, not just final parent. // Symlinked input must not be able to escape the whitelist during the // walk." Hence the loop here checks both the walk bound AND the still- // inside-whitelist invariant every step. import { access, realpath } from 'node:fs/promises'; import { constants } from 'node:fs'; import { dirname, isAbsolute, sep } from 'node:path'; import type { Sql } from '../db.js'; // Files whose presence in a directory marks it as a repo root for grant // purposes. Kept narrow on purpose; broader heuristics (e.g. ".project", // "pyproject.toml") can be added with measured intent. Each entry is a // literal basename — no globs. const REPO_MARKERS: ReadonlyArray = [ '.git', 'package.json', 'go.mod', 'Cargo.toml', ]; export type GrantResolution = | { ok: true; root: string; source: 'project' | 'whitelist' } | { ok: false; reason: string }; function isUnder(child: string, parent: string): boolean { return child === parent || child.startsWith(parent + sep); } async function exists(path: string): Promise { try { await access(path, constants.F_OK); return true; } catch { return false; } } async function isRepoShaped(dir: string): Promise { for (const marker of REPO_MARKERS) { if (await exists(`${dir}${sep}${marker}`)) return true; } return false; } // Resolves an absolute path to its grant root or refuses with a reason // string suitable for surfacing to the model. Pure helper — no DB writes, // no broker publishes. Caller persists the root on session.allowed_read_paths // if it wants the grant to stick. // // Arguments: // sql — used only to read projects.path (no writes) // requestedPath — absolute path the model wants to read // projectRoot — the session's primary project root (already // realpath'd by caller). Used to short-circuit // "already in scope". // whitelistRoot — PROJECT_ROOT_WHITELIST from config (default /opt). // Walk bound for the repo-shape fallback. // // Returns { ok: true, root, source } on success; { ok: false, reason } else. export async function resolveGrantRoot( sql: Sql, requestedPath: string, projectRoot: string, whitelistRoot: string, ): Promise { if (typeof requestedPath !== 'string' || requestedPath.length === 0) { return { ok: false, reason: 'path is required' }; } if (!isAbsolute(requestedPath)) { return { ok: false, reason: 'path must be absolute' }; } // Resolve symlinks so subsequent ancestor checks compare apples-to-apples // with realpath'd projectRoot. If the path doesn't exist at all, bail // before bothering the user — the model is asking about a phantom. let real: string; try { real = await realpath(requestedPath); } catch { return { ok: false, reason: `path does not exist: ${requestedPath}` }; } // Whitelist guard. Symlinked inputs can resolve outside the whitelist // even when the surface-form path looks inside it; that's why we test // the *real* path here, not the requested one. let realWhitelist: string; try { realWhitelist = await realpath(whitelistRoot); } catch { return { ok: false, reason: `whitelist root does not exist: ${whitelistRoot}` }; } if (!isUnder(real, realWhitelist)) { return { ok: false, reason: 'path outside permitted scope' }; } // Already in scope? No prompt needed; the tool's caller should retry. if (isUnder(real, projectRoot)) { return { ok: false, reason: 'path already accessible without a grant' }; } // Look for a registered project whose root is an ancestor of the // requested path. Pick the LONGEST match (nearest ancestor wins) so // sub-projects don't get over-broadened. const projectRows = await sql<{ path: string }[]>` SELECT path FROM projects WHERE status = 'open' `; let bestProject: string | null = null; for (const row of projectRows) { if (!row.path) continue; if (!isUnder(real, row.path)) continue; if (bestProject === null || row.path.length > bestProject.length) { bestProject = row.path; } } if (bestProject !== null) { return { ok: true, root: bestProject, source: 'project' }; } // Repo-shape fallback. Walk from the requested path upward toward the // whitelist root. At every iteration: confirm we're still inside the // whitelist (so a symlinked component can't slip the bound mid-walk) // and confirm we haven't hit the filesystem root. The first dir with a // REPO_MARKER child is the grant root. let cursor = real; while (true) { // Don't grant the whitelist root itself — that would be far too broad. if (cursor === realWhitelist) { return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' }; } if (!isUnder(cursor, realWhitelist)) { return { ok: false, reason: 'path outside permitted scope' }; } const parent = dirname(cursor); if (parent === cursor) { // Hit filesystem root without finding a repo marker. return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' }; } if (await isRepoShaped(cursor)) { return { ok: true, root: cursor, source: 'whitelist' }; } cursor = parent; } }