import { resolve, normalize } from 'node:path' import { realpathSync, existsSync } from 'node:fs' /** * Sensitive system paths that tools should never access. * Relative to filesystem root; checked after resolving symlinks. */ const BLOCKED_PATHS = ['/etc', '/proc', '/sys', '/dev', '/boot', '/root'] /** * The same list in canonical form. * * The check below compares against an already-resolved path, so the entries * must be resolved too: on macOS `/etc` is a symlink to `/private/etc`, and * comparing `/private/etc/x` against the literal `/etc` never matched — the * check was dead there. Entries that don't exist yet keep their literal form * (there is nothing to resolve, and a later `realpathSync` of the same path * cannot produce a different spelling). */ const BLOCKED_CANONICAL: string[] = BLOCKED_PATHS.map((p) => { try { return existsSync(p) ? realpathSync(p) : p } catch { return p } }) /** * Windows UNC paths and NT/Win32/DOS device-namespace prefixes. * * Accessing a UNC path (\\server\share) triggers SMB negotiation on Windows, * which silently sends NTLM credentials to the target — a credential-leak * vector (relay / offline cracking). The NT-namespace spelling (\??\UNC\...) * bypasses naive UNC checks, so all device-namespace prefixes are rejected too. * * Anchored at token boundaries so escaped backslashes (echo \\n) and local * drive paths (C:\\Users\\me) are NOT flagged, and http:// URLs are excluded * from the forward-slash form. */ const UNC_DEVICE_PATTERNS: RegExp[] = [ /(?:^|[\s"'`(;|&])\\\\[A-Za-z0-9._-]+\\[^\s"'`;|&]/, /(?:^|[\s"'`(;|&])\/\/[A-Za-z0-9._-]+\/[^\s"'`;|&]/, /\\\?\?\\/, /\\\\\?\\/, /\\\\\.\\/, ] export function isUncOrDevicePath(path: string): boolean { return UNC_DEVICE_PATTERNS.some((re) => re.test(path)) } /** * Resolve a user-supplied path relative to cwd, with sandbox enforcement. * * Rules: * - The resolved canonical path MUST be within `cwd` (or a subdirectory). * - Symlinks are resolved via realpathSync so attackers can't use * `ln -s /etc project/foo` to escape the sandbox. * - Sensitive system directories are always rejected. * - If the path doesn't exist yet (e.g. for Write), we resolve its parent * directory and check that the target is still within cwd. * * @returns The safe canonical path. * @throws {Error} with a human-readable message if the path is blocked. */ export function resolveSafe(cwd: string, inputPath: string): string { // Reject UNC / device-namespace paths before touching the filesystem — // resolving a UNC path on Windows would trigger SMB negotiation (NTLM leak). if (isUncOrDevicePath(inputPath)) { throw new Error( `Path rejected: "${inputPath}" is a UNC or device-namespace path (blocked to prevent NTLM credential leakage).`, ) } const raw = resolve(cwd, inputPath) const normalized = normalize(raw) // Resolve symlinks if the path (or its parent) already exists on disk let canonical: string if (existsSync(normalized)) { canonical = realpathSync(normalized) } else { // For paths that don't exist yet (e.g. Write tool creating a new file), // walk up to find the first existing parent, resolve that, then // reconstruct the full path. const existingParent = findExistingParent(normalized) if (!existingParent) { throw new Error(`Path rejected: no existing parent directory found for "${inputPath}"`) } const realParent = realpathSync(existingParent) const relative = normalized.slice(existingParent.length) canonical = realParent + relative } // Normalize cwd as well (it may contain symlinks) const realCwd = realpathSync(cwd) // Check 1: must be within cwd if (!isWithin(canonical, realCwd)) { throw new Error( `Path rejected: "${inputPath}" is outside the project workspace.\n` + `Resolved: ${canonical}\nWorkspace: ${realCwd}`, ) } // Check 2: must not target sensitive system directories for (const blocked of BLOCKED_CANONICAL) { if (canonical === blocked || canonical.startsWith(blocked + '/')) { throw new Error( `Path rejected: "${inputPath}" resolves to a protected system directory (${blocked}).`, ) } } return canonical } /** * Find the first existing parent directory of a path. * Returns undefined if no parent exists (shouldn't happen for valid paths). */ function findExistingParent(p: string): string | undefined { let current = p while (current.length > 1) { const parent = current.slice(0, current.lastIndexOf('/')) || '/' if (existsSync(parent)) return parent current = parent } return undefined } /** * Check if `child` is within `parent` (or equal to it). * * Compares path segments, not string prefixes: `/a/b-evil` is NOT within * `/a/b`. Both sides are expected to be resolved (no `..`, no trailing slash * beyond the root). The trailing-slash normalization below is for callers that * hand in an unresolved string such as `/proj/src/`. */ export function isWithin(child: string, parent: string): boolean { // Normalize trailing slashes for comparison const c = child.endsWith('/') ? child.slice(0, -1) : child const p = parent.endsWith('/') ? parent.slice(0, -1) : parent return c === p || c.startsWith(p + '/') }