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