UNPKG

@anthropic-ai/sdk

Version:
302 lines • 16.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.BetaNodeFilePolicy = void 0; exports.resolvesUnder = resolvesUnder; exports.betaCheckUploadPath = betaCheckUploadPath; /** * 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. */ const node_1 = require("../../../internal/node.js"); const ToolError_1 = require("../../tools/ToolError.js"); const errors_1 = require("./errors.js"); 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 errors_1.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 = node_1.path.isAbsolute(candidate) ? candidate : process.cwd() + node_1.path.sep + candidate; const remainder = []; for (;;) { try { const resolved = node_1.path.join(await node_1.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_1.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 node_1.fs.promises.lstat(current); exists = true; } catch { /* really absent */ } if (exists) throw new UploadOutsideRootsError(); if (err?.code !== 'ENOENT') { throw new UploadOutsideRootsError(); } const parent = node_1.path.dirname(current); if (parent === current) return node_1.path.join(current, ...remainder); const base = node_1.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(node_1.path.sep) ? b : b + node_1.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. */ 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. */ async function betaCheckUploadPath(candidate, roots) { if (candidate === '' || candidate.includes('\0')) { throw new errors_1.UploadRefusedError('file_upload path is empty or contains a NUL byte'); } if (candidate.length > MAX_UPLOAD_PATH_LENGTH) { throw new errors_1.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 errors_1.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 errors_1.UploadRefusedError(errors_1.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 errors_1.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(node_1.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 node_1.fs.promises.realpath(node_1.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 errors_1.ToolsetConfigError(`${option} does not resolve on disk (a dangling symlink or a cycle)`); let current = dir; for (;;) { try { node_1.fs.lstatSync(current); break; } catch (err) { if (err?.code !== 'ENOENT') throw refused(); const parent = node_1.path.dirname(current); if (parent === current) return; current = parent; } } if (!node_1.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. */ 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 errors_1.ToolsetConfigError('uploadRoots entries must be non-empty strings'); } if (options.downloadDir !== undefined && !options.downloadDir.trim()) { throw new errors_1.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) => node_1.path.resolve(root)); this.downloadDir = options.downloadDir === undefined ? undefined : node_1.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 errors_1.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 errors_1.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); } } exports.BetaNodeFilePolicy = BetaNodeFilePolicy; /** Whether `candidate`, normalised as text, is `directory` (as written or as it resolves) or below it. */ async function lexicallyUnder(candidate, directory) { const text = comparable(node_1.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(node_1.path.normalize(base)).replace(/[\\/]+$/, ''); if (text === folded || text.startsWith(folded + node_1.path.sep)) return true; } return false; } //# sourceMappingURL=security-node.js.map