UNPKG

@anthropic-ai/sdk

Version:
296 lines • 16 kB
/** * Upload-path allowlist and the Node file policy. Node-only: it resolves symlinks through the real * filesystem. * * Kept apart from the runtime-agnostic modules so that the public `helpers/beta/toolsets` entry stays importable in * browser and edge bundles: a static `fs`/`path` import at the top of a module reachable from it would pull the Node * built-ins into every bundle. Reached through `helpers/beta/toolsets/node`. The built-ins come through * `internal/node`, which browser builds stub, so importing this module there is harmless and using it throws. */ import { fs, path } from "../../../internal/node.mjs"; import { ToolError } from "../../tools/ToolError.mjs"; import { NO_UPLOAD_ROOTS, ToolsetConfigError, UploadRefusedError } from "./errors.mjs"; const MAX_UPLOAD_PATH_LENGTH = 4096; // Two leading separators of either kind: Windows reads "/\\host" and "\\/?" as UNC and device paths too. const UNC_OR_DEVICE_PATH_RE = /^[\\/]{2}/; const DOS_DEVICE_NAME_RE = /^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[0-9\u00b9\u00b2\u00b3]|LPT[0-9\u00b9\u00b2\u00b3])(?:[.:].*)?$/i; const MAX_REALPATH_PASSES = 8; /** The refusal for a path that is not, or cannot be shown to be, under a configured upload root. */ class UploadOutsideRootsError extends UploadRefusedError { constructor() { super('file_upload path is outside the configured upload roots'); } } /** * `realpath` that tolerates a not-yet-existing leaf: resolves the nearest existing ancestor and * re-appends the rest. * * Only a component that does not exist may be re-appended. A bare `catch` would also swallow ELOOP and a * dangling symlink, then return the lexical in-root path for an entry that actually points outside * every root, so the containment check would pass and the driver would follow the link at open time. */ async function realpathLenient(candidate, depth = 0) { // Made absolute but not normalised: a `..` that follows a symlinked component has to be resolved through // the link, as the OS will at open time. Collapsing it against the lexical parent first (path.resolve) // would judge `<dir>/link/../x` inside `<dir>` while it opens somewhere else. An upload or download path with a // `..` component never reaches here, but configured directories may contain one. let current = path.isAbsolute(candidate) ? candidate : process.cwd() + path.sep + candidate; const remainder = []; for (;;) { try { const resolved = path.join(await fs.promises.realpath(current), ...remainder); // path.join collapsed any `..` left in the absent tail. A collapsed `..` can point back inside existing // (possibly symlinked) directories, so resolve again until the answer stops changing. if (remainder.includes('..') && depth < MAX_REALPATH_PASSES) return await realpathLenient(resolved, depth + 1); return resolved; } catch (err) { if (err instanceof ToolError) throw err; // The entry exists (so this is a dangling symlink or a cycle, not a missing component): // there is no safe lexical answer for it. let exists = false; try { await fs.promises.lstat(current); exists = true; } catch { /* really absent */ } if (exists) throw new UploadOutsideRootsError(); if (err?.code !== 'ENOENT') { throw new UploadOutsideRootsError(); } const parent = path.dirname(current); if (parent === current) return path.join(current, ...remainder); const base = path.basename(current); if (base !== '' && base !== '.') remainder.unshift(base); current = parent; } } } function comparable(p) { return process.platform === 'win32' ? p.toLowerCase() : p; } /** Whether `a` is `b` or a descendant of it by whole path components. Both are compared as given. */ function sameOrUnder(a, b) { return a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep); } /** * Whether `candidate` names a network location or a device rather than a file on Windows: a UNC or device path * (two leading separators of either kind), or a path component whose stem (the text up to the first "." or * ":", trailing spaces and dots dropped, as Win32 reads it) is a DOS device name. Resolving such a path on * Windows connects to the named host or opens the device, so it is refused before anything touches the * filesystem. On every other platform these are ordinary names (`aux/notes.txt`, `con.pdf`) and this is false. * Like the rest of this module, the answer is for the host this process runs on. */ function namesNetworkOrDevice(candidate) { if (process.platform !== 'win32') return false; return (UNC_OR_DEVICE_PATH_RE.test(candidate) || candidate .split(/[\\/:]/) .some((part) => DOS_DEVICE_NAME_RE.test((part.split(/[.:]/, 1)[0] ?? '').replace(/[ .]+$/, '')))); } /** Whether `candidate` resolves to `root` or a descendant of it. Both sides may be not-yet-existing. */ export async function resolvesUnder(candidate, root) { // realpathLenient refuses a path it cannot resolve (a dangling symlink or a cycle) by throwing the // upload refusal. This is a predicate, and its callers are not the upload gate, so a path that will // not resolve is not under the root, and neither is one that names a network location or a device. if (namesNetworkOrDevice(candidate)) return false; try { return sameOrUnder(comparable(await realpathLenient(candidate)), comparable(await realpathLenient(root))); } catch { return false; } } /** * Default-deny upload allowlist: resolves to the real path when it is one of `roots` or below one by whole path * components, with symlinks resolved on both sides, and rejects with `UploadRefusedError` otherwise. The refusal * never lists the roots. Empty `roots` refuses everything. A root that does not exist on disk grants * nothing. A `..` component (and, on Windows, a UNC path or a DOS device name) is refused outright, before the * filesystem is touched. A dangling symlink or a symlink cycle under a root is refused rather than judged by its * target. Upload the returned path, not the argument. */ export async function betaCheckUploadPath(candidate, roots) { if (candidate === '' || candidate.includes('\0')) { throw new UploadRefusedError('file_upload path is empty or contains a NUL byte'); } if (candidate.length > MAX_UPLOAD_PATH_LENGTH) { throw new UploadRefusedError('file_upload path is too long'); } if (typeof roots === 'string') { // `Iterable<string>` is satisfied by a bare string, whose iteration yields characters: the // "/" element resolves to a parent of every absolute path and would approve everything. throw new ToolsetConfigError('roots must be a list of paths, not a single path'); } // An empty entry is not "the current directory is a root": `path.resolve('')` is the cwd, so a blank // root (an empty env var split on ",") would open the whole working tree. const rootList = [...roots].filter((r) => r.trim() !== ''); if (rootList.length === 0) throw new UploadRefusedError(NO_UPLOAD_ROOTS); // Refused before anything touches the filesystem, on Windows only: there, resolving a UNC path makes the // redirector connect to the named host with the user's credentials (a forced-auth leak) even // though the path is then refused, and a DOS device name (CON, COM1, NUL.txt) fails the // existence probe, is re-appended as a not-yet-existing tail under the root, and then opens the // device rather than a file. if (namesNetworkOrDevice(candidate)) { throw new UploadOutsideRootsError(); } // An upload path is model output (the deployer supplies the roots, the model the path), so a `..` // component is never legitimate, and it is where a lexical `..` collapse can disagree with symlink // resolution. Refuse it outright before resolving. if (candidate.split(/[\\/]/).includes('..')) { throw new UploadRefusedError("file_upload path must not contain a '..' component"); } // Decided on the text first: a path that does not claim to be under an upload root is refused without // touching the filesystem, so the model cannot make the SDK stat or resolve a path of its choosing (an existence // probe, an automounted share). A path that does claim to be under a root is still resolved and compared below, // so a symlink inside the root cannot lead out of it. const claimed = await Promise.all(rootList.map((root) => lexicallyUnder(path.resolve(candidate), root))); if (!claimed.includes(true)) { throw new UploadOutsideRootsError(); } const resolved = await realpathLenient(candidate); if (namesNetworkOrDevice(resolved)) { // On Windows, resolving a link to a share has already connected to its host with the user's credentials. // Screening the resolved path refuses the upload, but it cannot undo that connection. throw new UploadOutsideRootsError(); } for (const root of rootList) { let resolvedRoot; try { resolvedRoot = await fs.promises.realpath(path.resolve(root)); } catch { continue; } if (sameOrUnder(comparable(resolved), comparable(resolvedRoot))) return resolved; } throw new UploadOutsideRootsError(); } function conflicts(downloadDir, uploadRoots) { // The download-then-upload loop: a page-triggered download written to an upload root is readable back through // file_upload. Compared on the anchored paths as written: the constructor cannot wait for a promise, and following // symlinks here would need a second, synchronous copy of the resolver. Letter case and the Unicode form of a name // are ignored on every platform, since a macOS disk ignores both by default. const nests = (a, b) => sameOrUnder(a, b) || sameOrUnder(b, a); const literal = downloadDir.toLowerCase().normalize('NFC'); return uploadRoots.some((root) => nests(literal, root.toLowerCase().normalize('NFC'))); } /** * Refuses a configured directory that is, or sits under, a link that does not resolve (dangling, or in a cycle): the * overlap check compares paths as written, so such a path would pass it now and be followed later. Walks up to the * first part that `lstat` finds, then asks `existsSync`, which follows links. No resolver is needed. Only `ENOENT` * means "does not exist, go up": any other `lstat` error (`EACCES`, `ENOTDIR`) refuses the path, since a part that * cannot be inspected could be hiding a broken link. */ function assertResolves(dir, option) { const refused = () => new ToolsetConfigError(`${option} does not resolve on disk (a dangling symlink or a cycle)`); let current = dir; for (;;) { try { fs.lstatSync(current); break; } catch (err) { if (err?.code !== 'ENOENT') throw refused(); const parent = path.dirname(current); if (parent === current) return; current = parent; } } if (!fs.existsSync(current)) throw refused(); } /** * The file policy the SDK ships: `file_upload` may read paths under `uploadRoots` (symlinks resolved, * whole components compared, a `..` component refused outright) and may name the document ids listed in * `uploadDocumentIds` (none by default), and a `download_completed` path reaches the model only with * `exposeDownloadPaths: true` and only when it lies inside `downloadDir`. * * Keep the roots to one dedicated directory holding only the task's files, and put `downloadDir` * outside every upload root and outside anything another tool can reach. The driver decides where * downloads are written, and this object only decides what the model may see. */ export class BetaNodeFilePolicy { constructor(options = {}) { const roots = options.uploadRoots ?? []; if (roots.some((root) => root.trim() === '')) { // Anchoring an empty entry would silently make it the working directory. throw new ToolsetConfigError('uploadRoots entries must be non-empty strings'); } if (options.downloadDir !== undefined && !options.downloadDir.trim()) { throw new ToolsetConfigError('downloadDir must be a non-empty string'); } // Anchored once, here: re-resolving a relative root against the working directory at check time // would move the containment boundary. this.uploadRoots = roots.map((root) => path.resolve(root)); this.downloadDir = options.downloadDir === undefined ? undefined : path.resolve(options.downloadDir); this.exposeDownloadPaths = options.exposeDownloadPaths === true; this.uploadDocumentIds = new Set(options.uploadDocumentIds ?? []); // A configured directory that exists but will not resolve (a dangling link, a cycle) is refused now rather than // granting nothing (a root) or skipping the overlap check (downloadDir) and following the link later. for (const root of this.uploadRoots) assertResolves(root, 'an uploadRoots entry'); if (this.downloadDir !== undefined) assertResolves(this.downloadDir, 'downloadDir'); if (this.downloadDir !== undefined && conflicts(this.downloadDir, this.uploadRoots)) { throw new ToolsetConfigError('downloadDir must be outside every upload root, otherwise a page-triggered download landing in an upload root can be read back through file_upload'); } } async resolveUploadPaths(_ctx, paths) { const resolved = []; for (const p of paths) resolved.push(await betaCheckUploadPath(p, this.uploadRoots)); return resolved; } resolveUploadDocuments(_ctx, documentIds) { for (const id of documentIds) { // The refusal contains neither the id nor a count, so a steering page cannot learn which ids exist. if (!this.uploadDocumentIds.has(id)) throw new UploadRefusedError('document not in the upload allowlist'); } return [...documentIds]; } async isPathVisible(candidate) { if (!this.exposeDownloadPaths || this.downloadDir === undefined) return false; // Decided on the text alone first: a path that does not claim to be under the download directory is never // resolved, so a page cannot make the SDK stat a path of its choosing (an automounted share, an existence probe) // by getting it into a download event or an error message. if (candidate.split(/[\\/]/).includes('..')) return false; if (!(await lexicallyUnder(candidate, this.downloadDir))) return false; // Outside the quarantine directory through a symlink: the event still reaches the model, without the path. return resolvesUnder(candidate, this.downloadDir); } } /** Whether `candidate`, normalised as text, is `directory` (as written or as it resolves) or below it. */ async function lexicallyUnder(candidate, directory) { const text = comparable(path.normalize(candidate)); const bases = new Set([directory]); try { bases.add(await realpathLenient(directory)); } catch { // compared as written only } for (const base of bases) { const folded = comparable(path.normalize(base)).replace(/[\\/]+$/, ''); if (text === folded || text.startsWith(folded + path.sep)) return true; } return false; } //# sourceMappingURL=security-node.mjs.map