import path from 'node:path'; import fs from 'node:fs'; function isContained(candidate: string, root: string): boolean { return candidate === root || candidate.startsWith(root + path.sep); } function assertLexicalContainment(userPath: string, sandboxDir: string): string { const resolved = path.resolve(sandboxDir, userPath); const sandboxAbsolute = path.resolve(sandboxDir); if (!isContained(resolved, sandboxAbsolute)) { throw new SandboxEscapeError(userPath, sandboxDir, resolved); } return resolved; } /** * Resolve an existing path and verify both its lexical path and real symlink * target remain inside the sandbox. */ export function guardPath(userPath: string, sandboxDir: string): string { const resolved = assertLexicalContainment(userPath, sandboxDir); const sandboxReal = fs.realpathSync.native(sandboxDir); const resolvedReal = fs.realpathSync.native(resolved); if (!isContained(resolvedReal, sandboxReal)) { throw new SandboxEscapeError(userPath, sandboxDir, resolvedReal); } return resolvedReal; } /** * Resolve a writable file path whose parent already exists. Existing targets * are resolved fully. New targets use the real parent directory, which blocks * writes through a parent symlink that leaves the sandbox. */ export function guardWritePath(userPath: string, sandboxDir: string): string { const resolved = assertLexicalContainment(userPath, sandboxDir); const sandboxReal = fs.realpathSync.native(sandboxDir); let writableReal: string; try { writableReal = fs.realpathSync.native(resolved); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== 'ENOENT') throw error; const parentReal = fs.realpathSync.native(path.dirname(resolved)); writableReal = path.join(parentReal, path.basename(resolved)); } if (!isContained(writableReal, sandboxReal)) { throw new SandboxEscapeError(userPath, sandboxDir, writableReal); } return writableReal; } /** * Lexical-only validation for non-filesystem pathspecs such as `git diff --` * targets, where the path may name a deleted file and Git does not dereference * a tracked symlink. */ export function guardPathUnsafe(userPath: string, sandboxDir: string): string { return assertLexicalContainment(userPath, sandboxDir); } export class SandboxEscapeError extends Error { constructor( public readonly userPath: string, public readonly sandboxDir: string, public readonly resolvedPath: string, ) { super( `Path escape attempt blocked: "${userPath}" resolves to "${resolvedPath}" which is outside sandbox "${sandboxDir}"`, ); this.name = 'SandboxEscapeError'; } }