UNPKG

@anthropic-ai/sdk

Version:
198 lines • 8.51 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.FILE_CREATE_MODE = exports.DIR_CREATE_MODE = void 0; exports.isWithin = isWithin; exports.containingRoot = containingRoot; exports.errnoCode = errnoCode; exports.canonicalize = canonicalize; exports.confineToRoot = confineToRoot; exports.atomicWriteFile = atomicWriteFile; exports.fsErrorMessage = fsErrorMessage; const node_1 = require("../../internal/node.js"); const ToolError_1 = require("../../lib/tools/ToolError.js"); const fs = node_1.fs.promises; /** Mode for directories the file tools create: owner-only under any umask, like the memory tool. */ exports.DIR_CREATE_MODE = 0o700; /** * Mode for files the file tools create: owner-only, so other local users can't * read what an agent wrote. A file that already exists keeps its own mode. */ exports.FILE_CREATE_MODE = 0o600; /** True when `p` is `root` itself or lexically contained within it. */ function isWithin(root, p) { const rel = node_1.path.relative(root, p); return rel === '' || (!rel.startsWith('..' + node_1.path.sep) && rel !== '..' && !node_1.path.isAbsolute(rel)); } /** * The first entry of `roots` whose canonical form contains the * already-canonical `target`, returned as configured; `undefined` when none * does. Each root goes through {@link canonicalize} at check time, exactly like * the workdir in {@link confineToRoot}, so granting access (`allowedRoots`) * and refusing writes (`readOnlyRoots`) can never resolve the same entry two * different ways. */ async function containingRoot(roots, target) { for (const root of roots) { if (isWithin(await canonicalize(node_1.path.resolve(root)), target)) return root; } return undefined; } /** Matches Linux MAXSYMLINKS, the threshold at which `realpath` itself reports ELOOP. */ const MAX_SYMLINK_HOPS = 40; /** The `code` of a Node system error, or `undefined` for anything else. */ function errnoCode(err) { const code = err?.code; return typeof code === 'string' ? code : undefined; } /** * Fully resolve `abs`: `realpath` the longest existing ancestor and re-append * the rest, but never re-append a component that is itself a symlink — read the * link and continue from its target instead. This handles paths being created * (write/edit) without letting a symlink leaf (e.g. a dangling one pointing * outside a confinement root) slip through unresolved. * * Returns a symlink-free path or throws an errno-carrying error (`ELOOP` for a * cycle or more than {@link MAX_SYMLINK_HOPS} links, the `lstat`/`realpath` * error for an unreadable component); it never returns `abs` unresolved. Only * symlink hops count against the cap, so any depth of not-yet-existing * directories still resolves. */ async function canonicalize(abs) { const tail = []; let prefix = abs; let hops = 0; for (;;) { let real; try { real = await fs.realpath(prefix); } catch (realpathErr) { let isLink; try { isLink = (await fs.lstat(prefix)).isSymbolicLink(); } catch (lstatErr) { const code = errnoCode(lstatErr); if (code !== 'ENOENT' && code !== 'ENOTDIR') throw lstatErr; const parent = node_1.path.dirname(prefix); if (parent === prefix) throw lstatErr; tail.push(node_1.path.basename(prefix)); prefix = parent; continue; } if (!isLink) throw realpathErr; if (++hops > MAX_SYMLINK_HOPS) { throw Object.assign(new Error('too many levels of symbolic links'), { code: 'ELOOP' }); } prefix = node_1.path.resolve(node_1.path.dirname(prefix), await fs.readlink(prefix)); continue; } return tail.length ? node_1.path.join(real, ...tail.reverse()) : real; } } /** * Resolve `p` against `root` and confine it to `root` or one of `allowedRoots` * (absolute paths, resolved at check time exactly like `root`). * * Absolute and relative inputs go through the same canonicalise-then-contain * check — an absolute path that lands inside a permitted root is accepted, * only paths that resolve *outside* all of them are rejected. Every symlink in * `p` (including the leaf, even a dangling one) is resolved before the * confinement check, and the resolved path is what the caller then operates * on, so a symlink inside `root` that points outside it can neither pass the * check nor be followed afterwards. `..` is collapsed lexically before any * symlink is followed. A path that cannot be resolved (symlink loop, unreadable * component) is rejected with a `ToolError` naming `p`, never the host's * absolute path. * * Residual TOCTOU: a component could still be swapped for a symlink between this * call and the eventual `fs` operation. Closing that fully needs per-component * `O_NOFOLLOW`/`openat`, which Node does not expose ergonomically; this is why a * sandbox is still recommended for the toolset as a whole. */ async function confineToRoot(root, p, opts) { const allowedRoots = opts?.allowedRoots ?? []; const realRoot = await canonicalize(node_1.path.resolve(root)); let real; try { real = await canonicalize(node_1.path.resolve(realRoot, p)); } catch (err) { throw new ToolError_1.ToolError(fsErrorMessage(err, `path ${JSON.stringify(p)}`)); } if (isWithin(realRoot, real) || (await containingRoot(allowedRoots, real)) !== undefined) { return real; } const permitted = allowedRoots.length ? "the session's working directory and its other permitted directories" : "the session's working directory"; throw new ToolError_1.ToolError(`path ${JSON.stringify(p)} is outside ${permitted}`); } /** * Atomically write `content` to `targetPath`: write a sibling temp file, fsync * it, then rename over the target. The rename is atomic on most filesystems, so * a crash mid-write never leaves the target half-written. * * A new file is created {@link FILE_CREATE_MODE}; an existing one keeps its * permission bits (the rename replaces the inode, so they are copied onto the * temp file first) — an edit must not strip `+x` or sharing the owner chose. */ async function atomicWriteFile(targetPath, content) { const dir = node_1.path.dirname(targetPath); const tempPath = node_1.path.join(dir, `.tmp-${process.pid}-${node_1.crypto.randomUUID()}`); const existingMode = await fs.stat(targetPath).then((st) => st.mode & 0o777, () => undefined); let handle; try { handle = await fs.open(tempPath, 'wx', exports.FILE_CREATE_MODE); if (existingMode !== undefined) await handle.chmod(existingMode); await handle.writeFile(content, 'utf-8'); await handle.sync(); await handle.close(); handle = undefined; await fs.rename(tempPath, targetPath); } catch (err) { if (handle) await handle.close().catch(() => { }); await fs.unlink(tempPath).catch(() => { }); throw err; } } /** * Map a thrown filesystem error to a consistent, language-independent message, * so the model sees the same wording regardless of the runtime (Node's raw * `ENOENT: no such file...` text would otherwise leak through). Codes we don't * special-case render as the bare code, never Node's message, which embeds the * host's absolute path. */ function fsErrorMessage(err, file) { const code = errnoCode(err); switch (code) { case 'ENOENT': return `${file}: no such file or directory`; case 'EACCES': case 'EPERM': return `${file}: permission denied`; case 'ENOTDIR': return `${file}: not a directory`; case 'EISDIR': return `${file}: is a directory`; case 'ELOOP': return `${file}: too many levels of symbolic links`; case 'ENAMETOOLONG': return `${file}: file name too long`; case 'ENOSPC': return `${file}: no space left on device`; case 'EMFILE': case 'ENFILE': return `${file}: too many open files`; default: return `${file}: ${code !== undefined ? `i/o error (${code})` : 'i/o error'}`; } } //# sourceMappingURL=fs-util.js.map