@anthropic-ai/sdk
Version:
The official TypeScript library for the Anthropic API
296 lines • 16 kB
JavaScript
/**
* 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