import { realpath } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' /** * Resolve a caller-supplied path against a root, refusing anything that * lands outside it. * * The containment rule existed, in one private function inside the local * sandbox provider, and the filesystem tools never reached it. They called * `resolve(workingDirectory, input.path)` bare — so `path: "../../.."` * resolved to whatever is above the working directory and the tool read it * happily. That holds with no sandbox configured at all, which is the * common case, so the escape did not need a misconfiguration to reach: a * model that asks for a parent directory gets one. * * `relative()` rather than a `startsWith` prefix test: a prefix test says * `/workspace-backup` is inside `/workspace`, and the whole point is to be * exact about the boundary. */ export function resolveWithin(root: string, candidate: string | undefined): string { if (candidate === undefined || candidate === '') return resolve(root) const resolvedRoot = resolve(root) const resolved = resolve(resolvedRoot, candidate) const rel = relative(resolvedRoot, resolved) // An absolute `rel` means the two paths share no root at all (a // different drive on Windows); a leading `..` means it climbed out. if (rel.startsWith('..') || isAbsolute(rel)) { throw new Error( `Path escapes the working directory: ${candidate}. ${OUTSIDE_ROOTS_GUIDANCE(`inside ${resolvedRoot}`)}`, ) } return resolved } /** * What a refusal says after naming the path: where the tools DO reach, and * how that is widened. * * It used to end at "Tools may only reach inside X", which reads as a wall. * It is not one: a host can add a directory to the session, and a host that * reviews such paths (`QueryParams.outsideRootAccess: 'review'`) turns them * into an approval request before the tool ever runs, so a model that meets * this text is in a turn where that did not happen. Told only "may only", a * model either rewords the path forever or tells the user the file cannot be * read at all; told the route, it asks for the directory. */ export const OUTSIDE_ROOTS_GUIDANCE = (reach: string): string => `This turn's file tools reach ${reach}. The boundary is the session's, not the file system's: ask the user to add the directory to the session (or approve the path, where the host asks), rather than retrying another spelling of the same path.` /** * The directories a tool may reach: the working directory first, then any * the host added for the session (`/add-dir`), then any path a review * approved for THIS call (`ToolContext.approvedPaths`). Relative paths * resolve against the first; an absolute path is accepted inside any. */ export function toolRoots(context: { readonly workingDirectory: string readonly additionalDirectories?: readonly string[] readonly approvedPaths?: readonly string[] }): readonly string[] { return [ context.workingDirectory, ...(context.additionalDirectories ?? []), ...(context.approvedPaths ?? []), ] } /** * The absolute path `candidate` names when it lies outside every root, or * `undefined` when a tool would accept it as it stands. * * Decided with {@link resolveWithinAnyReal} — the resolver the file tools * run — so the review and the execution cannot disagree about which side of * the boundary a path is on, symlinks included. The returned path is the * lexical one, which is what {@link toolRoots} is then handed: a link inside * the working directory that points outside is approved under the name the * reviewer saw. */ export async function pathOutsideRoots( roots: readonly string[], candidate: string | undefined, ): Promise { if (candidate === undefined || candidate === '') return undefined const [first] = roots if (first === undefined) return undefined try { await resolveWithinAnyReal(roots, candidate) return undefined } catch { return resolve(resolve(first), candidate) } } function describeRoots(roots: readonly string[]): string { return roots.length === 1 ? `inside ${resolve(roots[0] as string)}` : `inside ${resolve(roots[0] as string)} or the added directories ${roots .slice(1) .map((r) => resolve(r)) .join(', ')}` } /** `resolveWithin` over several roots. See `toolRoots` for the order. */ export function resolveWithinAny(roots: readonly string[], candidate: string | undefined): string { const [first, ...rest] = roots if (first === undefined) throw new Error('No root to resolve within.') if (rest.length === 0) return resolveWithin(first, candidate) const resolved = candidate === undefined || candidate === '' ? resolve(first) : resolve(resolve(first), candidate) for (const root of roots) { if (isWithin(root, resolved)) return resolveWithin(root, resolved) } throw new Error( `Path escapes the working directory: ${candidate}. ${OUTSIDE_ROOTS_GUIDANCE(describeRoots(roots))}`, ) } /** `resolveWithinReal` over several roots: the first that contains the path, links followed. */ export async function resolveWithinAnyReal( roots: readonly string[], candidate: string | undefined, ): Promise { const [first, ...rest] = roots if (first === undefined) throw new Error('No root to resolve within.') if (rest.length === 0) return resolveWithinReal(first, candidate) const resolved = candidate === undefined || candidate === '' ? resolve(first) : resolve(resolve(first), candidate) let firstError: unknown for (const root of roots) { try { return await resolveWithinReal(root, resolved) } catch (err) { firstError ??= err } } throw new Error( `Path escapes the working directory: ${candidate}. ${OUTSIDE_ROOTS_GUIDANCE(describeRoots(roots))}`, { cause: firstError }, ) } export function isWithin(root: string, candidate: string): boolean { try { resolveWithin(root, candidate) return true } catch { return false } } /** * The same containment rule, decided after symlinks are resolved. * * {@link resolveWithin} is lexical, and a lexical check is not a boundary for * a tool that then follows links. `./notes -> /etc` passes it, because * `./notes/passwd` climbs nothing on paper; the write lands in `/etc`. That is * CWE-59, *Improper Link Resolution Before File Access*, and the mitigation * CWE-22 states for the family is the ordering this function exists to get * right: canonicalize first, validate the canonical form, never the input. * * `atomicWriteFile` makes the ordering load-bearing rather than theoretical. * It resolves the destination and writes THROUGH a link on purpose — so that * editing a linked file updates the target instead of replacing the link with * a regular file — which is correct behaviour and, paired with a lexical * check, is check-then-follow. * * Three things this has to get right that a single `realpath` does not: * * 1. **The root can itself be a symlink.** `os.tmpdir()` is one on macOS * (`/var/folders/…` under `/private`). Canonicalizing only the candidate * and comparing against a raw root rejects every path in a temp directory — * a containment check that refuses everything is not safer, it is broken, * and it fails in exactly the environment tests run in. * 2. **The target may not exist.** `write` creates files, and `realpath` on a * missing path throws. So this canonicalizes the deepest ancestor that DOES * exist and appends the rest lexically. The remainder cannot hide a link, * because nothing is there to be one. * 3. **The lexical check still runs first.** It costs nothing, refuses the * common `../../..` before touching the filesystem, and its message names * the offending input — which the canonical comparison, working on two * absolute paths, cannot. * * What this does NOT give you is TOCTOU safety. A component swapped for a * symlink between this check and the open would still be followed; closing * that needs per-component `openat`/`O_NOFOLLOW`, which Node does not expose. * The threat here is a link that is already there — a repository that contains * one, or one an earlier tool call created — not an attacker racing the * process on the user's own machine. */ export async function resolveWithinReal( root: string, candidate: string | undefined, ): Promise { // Cheap, and the only step that can name the caller's input in its error. const lexical = resolveWithin(root, candidate) // The root is canonicalized the same way as the candidate. A root that // does not exist yet is not hypothetical: a reviewer approves the path a // `write` is about to create (`ToolContext.approvedPaths`), and that path // is then a root of its own. `realpath` on it throws, and the old fallback // — the lexical root against a canonical candidate — refused the very file // the user had just said yes to. const realRoot = await canonicalize(resolve(root)) const canonical = await canonicalize(lexical) // The whole canonical path, not only its deepest existing ancestor: for a // root that exists the two agree (walking up stops at the root at the // latest), and for one that does not, only the whole path can equal it. // The part past the existing ancestor is lexical and cannot hide a link, // because nothing is there to be one. const rel = relative(realRoot, canonical) if (rel.startsWith('..') || isAbsolute(rel)) { throw new Error( `Path escapes the working directory: ${candidate}. It resolves through a link to ${canonical}, outside ${realRoot}. ${OUTSIDE_ROOTS_GUIDANCE(`inside ${realRoot}`)}`, ) } return canonical } /** * The canonical form of an absolute path that may not exist: the deepest * existing ancestor with its links resolved, and the rest appended as * written. A path of which nothing exists is returned as it stands. */ async function canonicalize(absolute: string): Promise { // Walk up to the deepest existing ancestor. Terminates at the filesystem // root, where `dirname` becomes a fixed point. let existing = absolute let remainder = '' for (;;) { const found = await realpath(existing).then( (value) => value, () => undefined, ) if (found !== undefined) return remainder ? join(found, remainder) : found const parent = dirname(existing) // Nothing on this branch exists at all, so there is no link to follow // and the lexical answer is already the canonical one. if (parent === existing) return absolute remainder = remainder ? join(basename(existing), remainder) : basename(existing) existing = parent } }