import fs from "node:fs"; import fsPromises from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import path from "node:path"; import { writeFileWithinRoot } from "openclaw/plugin-sdk/infra-runtime"; import type {
SandboxFsBridge,
SandboxFsStat,
SandboxResolvedPath,
} from "openclaw/plugin-sdk/sandbox"; import { createWritableRenameTargetResolver } from "openclaw/plugin-sdk/sandbox"; import type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js"; import { movePathWithCopyFallback } from "./mirror.js";
async function openPinnedReadableFile(params: {
absolutePath: string;
rootPath: string;
containerPath: string;
}): Promise<FileHandle> { // The literal root is what `resolveTarget` joins caller-provided relative // paths against, so pre-open containment must be checked in literal form. // The canonical root is derived separately and used for the post-open // path checks (fd-path readlink and realpath cross-check), so a workspace // that is itself configured as a symlink still works. const literalRoot = path.resolve(params.rootPath); const canonicalRoot = await fsPromises.realpath(literalRoot).catch(() => literalRoot); const literalPath = path.resolve(params.absolutePath); // Cheap string-prefix check on the caller-provided absolute path; no // filesystem state is read here, so there is no TOCTOU window. Deeper // checks run after the fd is pinned. if (!isPathInside(literalRoot, literalPath)) { thrownew Error(`Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`);
} const { flags: openReadFlags, supportsNoFollow } = resolveOpenReadFlags(); // Open first so every later check runs against an fd that is already pinned // to one specific inode. `O_NOFOLLOW` prevents the final path component from // being a symlink; the ancestor walk below handles parent-directory symlink // swaps on platforms where fd-path readlink is not available. const handle = await fsPromises.open(literalPath, openReadFlags); try { const openedStat = await handle.stat(); if (!openedStat.isFile()) { thrownew Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
} if (openedStat.nlink > 1) { thrownew Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
} const resolvedPath = await resolveOpenedReadablePath(handle.fd); if (resolvedPath !== null) { // Primary guarantee on Linux: the fd's resolved path is derived from the // kernel, so a parent-directory swap cannot make this return a stale path. if (!isPathInside(canonicalRoot, resolvedPath)) { thrownew Error(
`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`,
);
} return handle;
} // Fallback for platforms where fd-path readlink is unavailable. On macOS, // `/dev/fd/N` is a character device so readlink returns EINVAL; on Windows // there is no `/proc` equivalent. With no kernel-backed path readback we // must prove the pinned fd is in-root without trusting a separate // `realpath` + `lstat` pair that would race between the two awaits. Walk // every ancestor between `literalRoot` and `literalPath` — the actual // on-disk chain — and reject if any ancestor is a symlink, then use a // single `stat` call to confirm that the path still resolves to the // same file the fd has pinned. `fs.promises.stat` resolves the path and // returns the final file's identity in one syscall, so there is no // between-await window for an attacker to race.
await assertAncestorChainHasNoSymlinks(literalRoot, literalPath, params.containerPath, { // On platforms where `O_NOFOLLOW` is unavailable (Windows), the open // call would have transparently followed a final-component symlink, so // the ancestor walk has to lstat the leaf as well.
includeLeaf: !supportsNoFollow,
}); const currentResolvedStat = await fsPromises.stat(literalPath); if (!sameFileIdentity(currentResolvedStat, openedStat)) { thrownew Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
} // Belt-and-suspenders: re-fstat the pinned fd after the identity check and // confirm the file type and link count are still trustworthy. A hardlink // that appeared between the initial fstat and here is not exploitable for // the read (the fd is already pinned to the original inode), but failing // closed here keeps the guarantee simple: the bytes we return always come // from a file that was a single-linked regular file at verification time. const postCheckStat = await handle.stat(); if (!postCheckStat.isFile() || postCheckStat.nlink > 1) { thrownew Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
} return handle;
} catch (error) {
await handle.close(); throw error;
}
}
// Walks each directory between canonicalRoot (exclusive) and // targetAbsolutePath, `lstat`'ing each segment. Rejects if any intermediate // segment is a symlink or a non-directory. By default the final component is // not walked because `O_NOFOLLOW` already protects it on the open call. Pass // `includeLeaf: true` on platforms where `O_NOFOLLOW` is unavailable // (Windows) so a symlinked leaf cannot be followed silently by `open`.
async function assertAncestorChainHasNoSymlinks(
canonicalRoot: string,
targetAbsolutePath: string,
containerPath: string,
options: { includeLeaf?: boolean } = {},
): Promise<void> { const relative = path.relative(canonicalRoot, targetAbsolutePath); if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { return;
} const segments = relative.split(path.sep).filter((segment) => segment.length > 0); const lastIndex = options.includeLeaf ? segments.length : segments.length - 1;
let cursor = canonicalRoot; for (let i = 0; i < lastIndex; i += 1) {
cursor = path.join(cursor, segments[i]); const stat = await fsPromises.lstat(cursor).catch(() => null); if (!stat) { thrownew Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`);
} const isLeaf = i === segments.length - 1; if (stat.isSymbolicLink()) { thrownew Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`);
} if (!isLeaf && !stat.isDirectory()) { thrownew Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`);
}
}
}
type ReadOpenFlagsResolution = { flags: number; supportsNoFollow: boolean };
let readOpenFlagsResolverForTest: (() => ReadOpenFlagsResolution) | undefined;
// Resolves the absolute path associated with an open fd via the kernel-backed // `/proc/self/fd/<fd>` (Linux) or `/dev/fd/<fd>` (some BSDs). Returns null // when no fd-path endpoint is available. Note: on macOS `/dev/fd/N` is a // character device rather than a symlink, so `readlink` fails with EINVAL // there and the caller must use the ancestor-walk fallback instead.
async function resolveOpenedReadablePath(fd: number): Promise<string | null> { for (const fdPath of [`/proc/self/fd/${fd}`, `/dev/fd/${fd}`]) { try { const openedPath = await fsPromises.readlink(fdPath); return normalizeOpenedReadablePath(openedPath);
} catch { continue;
}
} returnnull;
}
// File identity comparison with win32-aware `dev=0` handling, matching the // shared `src/infra/file-identity.ts` contract. Kept local because extension // production code is not allowed to reach into core `src/**` by relative // import, and this helper is not yet part of the `openclaw/plugin-sdk/*` // public surface. Stats here come from `FileHandle.stat()` / `fs.promises.stat()` // with no `{ bigint: true }` option, so all fields are numbers. function sameFileIdentity(
left: { dev: number; ino: number },
right: { dev: number; ino: number },
platform: NodeJS.Platform = process.platform,
): boolean { if (left.ino !== right.ino) { returnfalse;
} if (left.dev === right.dev) { returntrue;
} // On Windows, path-based stat can report `dev=0` while fd-based stat reports // a real volume serial. Treat either side `dev=0` as "unknown device" // rather than a mismatch so legitimate Windows fallback reads are not // rejected. return platform === "win32" && (left.dev === 0 || right.dev === 0);
}
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.