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/file-access-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"; type ResolvedMountPath = SandboxResolvedPath & { mountHostRoot: string; writable: boolean; source: "workspace" | "agent"; }; export function createOpenShellFsBridge(params: { sandbox: OpenShellFsBridgeContext; backend: OpenShellSandboxBackend; }): SandboxFsBridge { return new OpenShellFsBridge(params.sandbox, params.backend); } class OpenShellFsBridge implements SandboxFsBridge { private readonly resolveRenameTargets = createWritableRenameTargetResolver( (target) => this.resolveTarget(target), (target, action) => this.ensureWritable(target, action), ); constructor( private readonly sandbox: OpenShellFsBridgeContext, private readonly backend: OpenShellSandboxBackend, ) {} resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { const target = this.resolveTarget(params); return { hostPath: target.hostPath, relativePath: target.relativePath, containerPath: target.containerPath, }; } async readFile(params: { filePath: string; cwd?: string; signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); const hostPath = this.requireHostPath(target); const handle = await openPinnedReadableFile({ absolutePath: hostPath, rootPath: target.mountHostRoot, containerPath: target.containerPath, }); try { return (await handle.readFile()) as Buffer; } finally { await handle.close(); } } async writeFile(params: { filePath: string; cwd?: string; data: Buffer | string; encoding?: BufferEncoding; mkdir?: boolean; signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); const hostPath = this.requireHostPath(target); this.ensureWritable(target, "write files"); await assertLocalPathSafety({ target, root: target.mountHostRoot, allowMissingLeaf: true, allowFinalSymlinkForUnlink: false, }); const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data, params.encoding ?? "utf8"); await writeFileWithinRoot({ rootDir: target.mountHostRoot, relativePath: path.relative(target.mountHostRoot, hostPath), data: buffer, mkdir: params.mkdir, }); await this.backend.syncLocalPathToRemote(hostPath, target.containerPath); } async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { const target = this.resolveTarget(params); const hostPath = this.requireHostPath(target); this.ensureWritable(target, "create directories"); await assertLocalPathSafety({ target, root: target.mountHostRoot, allowMissingLeaf: true, allowFinalSymlinkForUnlink: false, }); await fsPromises.mkdir(hostPath, { recursive: true }); await this.backend.runRemoteShellScript({ script: 'mkdir -p -- "$1"', args: [target.containerPath], signal: params.signal, }); } async remove(params: { filePath: string; cwd?: string; recursive?: boolean; force?: boolean; signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); const hostPath = this.requireHostPath(target); this.ensureWritable(target, "remove files"); await assertLocalPathSafety({ target, root: target.mountHostRoot, allowMissingLeaf: params.force !== false, allowFinalSymlinkForUnlink: true, }); await fsPromises.rm(hostPath, { recursive: params.recursive ?? false, force: params.force !== false, }); await this.backend.runRemoteShellScript({ script: params.recursive ? 'rm -rf -- "$1"' : 'if [ -d "$1" ] && [ ! -L "$1" ]; then rmdir -- "$1"; elif [ -e "$1" ] || [ -L "$1" ]; then rm -f -- "$1"; fi', args: [target.containerPath], signal: params.signal, allowFailure: params.force !== false, }); } async rename(params: { from: string; to: string; cwd?: string; signal?: AbortSignal; }): Promise { const { from, to } = this.resolveRenameTargets(params); const fromHostPath = this.requireHostPath(from); const toHostPath = this.requireHostPath(to); await assertLocalPathSafety({ target: from, root: from.mountHostRoot, allowMissingLeaf: false, allowFinalSymlinkForUnlink: true, }); await assertLocalPathSafety({ target: to, root: to.mountHostRoot, allowMissingLeaf: true, allowFinalSymlinkForUnlink: false, }); await fsPromises.mkdir(path.dirname(toHostPath), { recursive: true }); await movePathWithCopyFallback({ from: fromHostPath, to: toHostPath }); await this.backend.runRemoteShellScript({ script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"', args: [from.containerPath, to.containerPath], signal: params.signal, }); } async stat(params: { filePath: string; cwd?: string; signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); const hostPath = this.requireHostPath(target); const stats = await fsPromises.lstat(hostPath).catch(() => null); if (!stats) { return null; } await assertLocalPathSafety({ target, root: target.mountHostRoot, allowMissingLeaf: false, allowFinalSymlinkForUnlink: false, }); return { type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other", size: stats.size, mtimeMs: stats.mtimeMs, }; } private ensureWritable(target: ResolvedMountPath, action: string) { if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); } } private requireHostPath(target: ResolvedMountPath): string { if (!target.hostPath) { throw new Error( `OpenShell mirror bridge requires a local host path: ${target.containerPath}`, ); } return target.hostPath; } private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath { const workspaceRoot = path.resolve(this.sandbox.workspaceDir); const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); const hasAgentMount = this.sandbox.workspaceAccess !== "none" && workspaceRoot !== agentRoot; const agentContainerRoot = (this.backend.remoteAgentWorkspaceDir || "/agent").replace( /\\/g, "/", ); const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/"); const input = params.filePath.trim(); if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) { const relative = path.posix.relative(workspaceContainerRoot, input) || ""; const hostPath = relative ? path.resolve(workspaceRoot, ...relative.split("/")) : workspaceRoot; return { hostPath, relativePath: relative, containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot, mountHostRoot: workspaceRoot, writable: this.sandbox.workspaceAccess === "rw", source: "workspace", }; } if ( hasAgentMount && (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot) ) { const relative = path.posix.relative(agentContainerRoot, input) || ""; const hostPath = relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot; return { hostPath, relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot, containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot, mountHostRoot: agentRoot, writable: this.sandbox.workspaceAccess === "rw", source: "agent", }; } const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input); if (isPathInside(workspaceRoot, hostPath)) { const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep); return { hostPath, relativePath: relative, containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot, mountHostRoot: workspaceRoot, writable: this.sandbox.workspaceAccess === "rw", source: "workspace", }; } if (hasAgentMount && isPathInside(agentRoot, hostPath)) { const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep); return { hostPath, relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot, containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot, mountHostRoot: agentRoot, writable: this.sandbox.workspaceAccess === "rw", source: "agent", }; } throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`); } } function isPathInside(root: string, target: string): boolean { const relative = path.relative(root, target); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } async function assertLocalPathSafety(params: { target: ResolvedMountPath; root: string; allowMissingLeaf: boolean; allowFinalSymlinkForUnlink: boolean; }): Promise { if (!params.target.hostPath) { throw new Error(`Missing local host path for ${params.target.containerPath}`); } const canonicalRoot = await fsPromises .realpath(params.root) .catch(() => path.resolve(params.root)); const candidate = await resolveCanonicalCandidate(params.target.hostPath); if (!isPathInside(canonicalRoot, candidate)) { throw new Error( `Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`, ); } const relative = path.relative(params.root, params.target.hostPath); const segments = relative .split(path.sep) .filter(Boolean) .slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length)); let cursor = params.root; for (let index = 0; index < segments.length; index += 1) { cursor = path.join(cursor, segments[index]); const stats = await fsPromises.lstat(cursor).catch(() => null); if (!stats) { if (index === segments.length - 1 && params.allowMissingLeaf) { return; } continue; } const isFinal = index === segments.length - 1; if (stats.isSymbolicLink() && (!isFinal || !params.allowFinalSymlinkForUnlink)) { throw new Error(`Sandbox boundary checks failed: ${params.target.containerPath}`); } } } async function resolveCanonicalCandidate(targetPath: string): Promise { const missing: string[] = []; let cursor = path.resolve(targetPath); while (true) { const exists = await fsPromises .lstat(cursor) .then(() => true) .catch(() => false); if (exists) { const canonical = await fsPromises.realpath(cursor).catch(() => cursor); return path.resolve(canonical, ...missing); } const parent = path.dirname(cursor); if (parent === cursor) { return path.resolve(cursor, ...missing); } missing.unshift(path.basename(cursor)); cursor = parent; } } async function openPinnedReadableFile(params: { absolutePath: string; rootPath: string; containerPath: string; }): Promise { // 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)) { throw new 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()) { throw new Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`); } if (openedStat.nlink > 1) { throw new 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)) { throw new 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)) { throw new 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) { throw new 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 { 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) { throw new Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`); } const isLeaf = i === segments.length - 1; if (stat.isSymbolicLink()) { throw new Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`); } if (!isLeaf && !stat.isDirectory()) { throw new Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`); } } } type ReadOpenFlagsResolution = { flags: number; supportsNoFollow: boolean }; let readOpenFlagsResolverForTest: (() => ReadOpenFlagsResolution) | undefined; function resolveOpenReadFlags(): ReadOpenFlagsResolution { if (readOpenFlagsResolverForTest) { return readOpenFlagsResolverForTest(); } const closeOnExec = (fs.constants as Record).O_CLOEXEC ?? 0; const supportsNoFollow = typeof fs.constants.O_NOFOLLOW === "number"; const noFollow = supportsNoFollow ? fs.constants.O_NOFOLLOW : 0; return { flags: fs.constants.O_RDONLY | noFollow | closeOnExec, supportsNoFollow, }; } /** * Test-only seam for forcing the open-flag/`O_NOFOLLOW` resolution. Used to * exercise the Windows-style fallback (no `O_NOFOLLOW`, ancestor walk * includes the leaf) on platforms where `fs.constants.O_NOFOLLOW` is a * non-configurable native data property and cannot be patched directly. * * @internal */ export function setReadOpenFlagsResolverForTest( resolver: (() => ReadOpenFlagsResolution) | undefined, ): void { readOpenFlagsResolverForTest = resolver; } // Resolves the absolute path associated with an open fd via the kernel-backed // `/proc/self/fd/` (Linux) or `/dev/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 { for (const fdPath of [`/proc/self/fd/${fd}`, `/dev/fd/${fd}`]) { try { const openedPath = await fsPromises.readlink(fdPath); return normalizeOpenedReadablePath(openedPath); } catch { continue; } } return null; } function normalizeOpenedReadablePath(openedPath: string): string { const deletedSuffix = " (deleted)"; const withoutDeletedSuffix = openedPath.endsWith(deletedSuffix) ? openedPath.slice(0, -deletedSuffix.length) : openedPath; return path.resolve(withoutDeletedSuffix); } // 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) { return false; } if (left.dev === right.dev) { return true; } // 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); }