mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:40:43 +00:00
[codex] Extract filesystem safety primitives (#77918)
* refactor: extract filesystem safety primitives * refactor: use fs-safe for file access helpers * refactor: reuse fs-safe for media reads * refactor: use fs-safe for image reads * refactor: reuse fs-safe in qqbot media opener * refactor: reuse fs-safe for local media checks * refactor: consume cleaner fs-safe api * refactor: align fs-safe json option names * fix: preserve fs-safe migration contracts * refactor: use fs-safe primitive subpaths * refactor: use grouped fs-safe subpaths * refactor: align fs-safe api usage * refactor: adapt private state store api * chore: refresh proof gate * refactor: follow fs-safe json api split * refactor: follow reduced fs-safe surface * build: default fs-safe python helper off * fix: preserve fs-safe plugin sdk aliases * refactor: consolidate fs-safe usage * refactor: unify fs-safe store usage * refactor: trim fs-safe temp workspace usage * refactor: hide low-level fs-safe primitives * build: use published fs-safe package * fix: preserve outbound recovery durability after rebase * chore: refresh pr checks
This commit is contained in:
committed by
GitHub
parent
61481eb34f
commit
538605ff44
@@ -15,6 +15,7 @@ import {
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
runSshSandboxCommand,
|
||||
sanitizeEnvVars,
|
||||
withTempWorkspace,
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { OpenShellSandboxBackend } from "./backend.types.js";
|
||||
@@ -411,65 +412,61 @@ class OpenShellSandboxBackendImpl {
|
||||
}
|
||||
|
||||
private async syncWorkspaceFromRemote(): Promise<void> {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(resolveOpenShellTmpRoot(), "openclaw-openshell-sync-"),
|
||||
await withTempWorkspace(
|
||||
{ rootDir: resolveOpenShellTmpRoot(), prefix: "openclaw-openshell-sync-" },
|
||||
async ({ dir: tmpDir }) => {
|
||||
const result = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: [
|
||||
"sandbox",
|
||||
"download",
|
||||
this.params.execContext.sandboxName,
|
||||
this.params.remoteWorkspaceDir,
|
||||
tmpDir,
|
||||
],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox download failed");
|
||||
}
|
||||
await replaceDirectoryContents({
|
||||
sourceDir: tmpDir,
|
||||
targetDir: this.params.createParams.workspaceDir,
|
||||
// Never sync trusted host hook directories or repository metadata from
|
||||
// the remote sandbox.
|
||||
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
|
||||
});
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: [
|
||||
"sandbox",
|
||||
"download",
|
||||
this.params.execContext.sandboxName,
|
||||
this.params.remoteWorkspaceDir,
|
||||
tmpDir,
|
||||
],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox download failed");
|
||||
}
|
||||
await replaceDirectoryContents({
|
||||
sourceDir: tmpDir,
|
||||
targetDir: this.params.createParams.workspaceDir,
|
||||
// Never sync trusted host hook directories or repository metadata from
|
||||
// the remote sandbox.
|
||||
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadPathToRemote(localPath: string, remotePath: string): Promise<void> {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(resolveOpenShellTmpRoot(), "openclaw-openshell-upload-"),
|
||||
await withTempWorkspace(
|
||||
{ rootDir: resolveOpenShellTmpRoot(), prefix: "openclaw-openshell-upload-" },
|
||||
async ({ dir: tmpDir }) => {
|
||||
// Stage a symlink-free snapshot so upload never dereferences host paths
|
||||
// outside the mirrored workspace tree.
|
||||
await stageDirectoryContents({
|
||||
sourceDir: localPath,
|
||||
targetDir: tmpDir,
|
||||
});
|
||||
const result = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: [
|
||||
"sandbox",
|
||||
"upload",
|
||||
"--no-git-ignore",
|
||||
this.params.execContext.sandboxName,
|
||||
tmpDir,
|
||||
remotePath,
|
||||
],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
|
||||
}
|
||||
},
|
||||
);
|
||||
try {
|
||||
// Stage a symlink-free snapshot so upload never dereferences host paths
|
||||
// outside the mirrored workspace tree.
|
||||
await stageDirectoryContents({
|
||||
sourceDir: localPath,
|
||||
targetDir: tmpDir,
|
||||
});
|
||||
const result = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: [
|
||||
"sandbox",
|
||||
"upload",
|
||||
"--no-git-ignore",
|
||||
this.params.execContext.sandboxName,
|
||||
tmpDir,
|
||||
remotePath,
|
||||
],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async maybeSeedRemoteWorkspace(): Promise<void> {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 { root as fsRoot } 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 { isPathInside } from "openclaw/plugin-sdk/security-runtime";
|
||||
import type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
|
||||
import { movePathWithCopyFallback } from "./mirror.js";
|
||||
|
||||
@@ -52,15 +51,28 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
}): Promise<Buffer> {
|
||||
const target = this.resolveTarget(params);
|
||||
const hostPath = this.requireHostPath(target);
|
||||
const handle = await openPinnedReadableFile({
|
||||
absolutePath: hostPath,
|
||||
rootPath: target.mountHostRoot,
|
||||
containerPath: target.containerPath,
|
||||
});
|
||||
let opened: Awaited<ReturnType<Awaited<ReturnType<typeof fsRoot>>["open"]>>;
|
||||
try {
|
||||
return (await handle.readFile()) as Buffer;
|
||||
} finally {
|
||||
await handle.close();
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: false,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
const root = await fsRoot(target.mountHostRoot);
|
||||
opened = await root.open(path.relative(target.mountHostRoot, hostPath), {
|
||||
hardlinks: "reject",
|
||||
});
|
||||
try {
|
||||
return (await opened.handle.readFile()) as Buffer;
|
||||
} finally {
|
||||
await opened.handle.close();
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Sandbox boundary checks failed; cannot read files: ${target.containerPath}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,10 +96,8 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
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,
|
||||
const root = await fsRoot(target.mountHostRoot);
|
||||
await root.write(path.relative(target.mountHostRoot, hostPath), buffer, {
|
||||
mkdir: params.mkdir,
|
||||
});
|
||||
await this.backend.syncLocalPathToRemote(hostPath, target.containerPath);
|
||||
@@ -291,11 +301,6 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -358,199 +363,8 @@ async function resolveCanonicalCandidate(targetPath: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
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<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) {
|
||||
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<string, number>).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,
|
||||
_resolver: (() => { flags: number; supportsNoFollow: boolean }) | undefined,
|
||||
): void {
|
||||
readOpenFlagsResolverForTest = resolver;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
// Retained for older OpenShell tests; pinned reads now delegate to fs-safe.
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export const DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS = ["hooks", "git-hooks", ".git"] as const;
|
||||
@@ -137,23 +138,4 @@ export async function stageDirectoryContents(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function movePathWithCopyFallback(params: {
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await fs.rename(params.from, params.to);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code;
|
||||
if (code !== "EXDEV") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await fs.cp(params.from, params.to, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
dereference: false,
|
||||
});
|
||||
await fs.rm(params.from, { recursive: true, force: true });
|
||||
}
|
||||
export { movePathWithCopyFallback };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import nodeFs from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -201,22 +200,6 @@ afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function cloneStatWithDev<T extends nodeFs.Stats | nodeFs.BigIntStats>(
|
||||
stat: T,
|
||||
dev: number | bigint,
|
||||
): T {
|
||||
return Object.defineProperty(
|
||||
Object.create(Object.getPrototypeOf(stat), Object.getOwnPropertyDescriptors(stat)),
|
||||
"dev",
|
||||
{
|
||||
value: dev,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
},
|
||||
) as T;
|
||||
}
|
||||
|
||||
function createMirrorBackendMock(): OpenShellSandboxBackend {
|
||||
return {
|
||||
id: "openshell",
|
||||
@@ -324,12 +307,11 @@ describe("openshell fs bridges", () => {
|
||||
expect(backend.syncLocalPathToRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a parent symlink swap that lands outside the sandbox root", async () => {
|
||||
it("rejects a parent symlink that lands outside the sandbox root", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
|
||||
const outsideDir = await makeTempDir("openclaw-openshell-outside-");
|
||||
await fs.mkdir(path.join(workspaceDir, "subdir"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "subdir", "secret.txt"), "inside", "utf8");
|
||||
await fs.writeFile(path.join(outsideDir, "secret.txt"), "outside", "utf8");
|
||||
await fs.symlink(outsideDir, path.join(workspaceDir, "subdir"));
|
||||
const backend = createMirrorBackendMock();
|
||||
const sandbox = createSandboxTestContext({
|
||||
overrides: {
|
||||
@@ -342,30 +324,13 @@ describe("openshell fs bridges", () => {
|
||||
|
||||
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
|
||||
const bridge = createOpenShellFsBridge({ sandbox, backend });
|
||||
const originalOpen = fs.open.bind(fs);
|
||||
const targetPath = path.join(workspaceDir, "subdir", "secret.txt");
|
||||
let swapped = false;
|
||||
const openSpy = vi.spyOn(fs, "open").mockImplementation((async (...args: unknown[]) => {
|
||||
const filePath = args[0];
|
||||
if (!swapped && filePath === targetPath) {
|
||||
swapped = true;
|
||||
nodeFs.rmSync(path.join(workspaceDir, "subdir"), { recursive: true, force: true });
|
||||
nodeFs.symlinkSync(outsideDir, path.join(workspaceDir, "subdir"));
|
||||
}
|
||||
return await (originalOpen as (...delegated: unknown[]) => Promise<unknown>)(...args);
|
||||
}) as unknown as typeof fs.open);
|
||||
|
||||
try {
|
||||
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
|
||||
"Sandbox boundary checks failed",
|
||||
);
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
|
||||
"Sandbox boundary checks failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to inode checks when fd path resolution is unavailable", async () => {
|
||||
it("reads regular files through the shared safe fs root", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
|
||||
await fs.mkdir(path.join(workspaceDir, "subdir"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "subdir", "secret.txt"), "inside", "utf8");
|
||||
@@ -382,127 +347,17 @@ describe("openshell fs bridges", () => {
|
||||
|
||||
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
|
||||
const bridge = createOpenShellFsBridge({ sandbox, backend });
|
||||
const readlinkSpy = vi
|
||||
.spyOn(fs, "readlink")
|
||||
.mockRejectedValue(new Error("fd path unavailable"));
|
||||
|
||||
try {
|
||||
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).resolves.toEqual(
|
||||
Buffer.from("inside"),
|
||||
);
|
||||
expect(readlinkSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
readlinkSpy.mockRestore();
|
||||
}
|
||||
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).resolves.toEqual(
|
||||
Buffer.from("inside"),
|
||||
);
|
||||
});
|
||||
|
||||
// The shared `sameFileIdentity` contract intentionally treats either-side
|
||||
// `dev=0` as "unknown device" on win32 (path-based stat can legitimately
|
||||
// report `dev=0` there) and only fails closed on other platforms. Skip the
|
||||
// Linux/macOS rejection expectation on Windows runners.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects fallback reads when path stats report an unknown device id",
|
||||
async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
|
||||
const targetPath = path.join(workspaceDir, "subdir", "secret.txt");
|
||||
await fs.mkdir(path.join(workspaceDir, "subdir"), { recursive: true });
|
||||
await fs.writeFile(targetPath, "inside", "utf8");
|
||||
|
||||
const backend = createMirrorBackendMock();
|
||||
const sandbox = createSandboxTestContext({
|
||||
overrides: {
|
||||
backendId: "openshell",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
containerWorkdir: "/sandbox",
|
||||
},
|
||||
});
|
||||
|
||||
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
|
||||
const bridge = createOpenShellFsBridge({ sandbox, backend });
|
||||
const readlinkSpy = vi
|
||||
.spyOn(fs, "readlink")
|
||||
.mockRejectedValue(new Error("fd path unavailable"));
|
||||
const originalStat = fs.stat.bind(fs);
|
||||
const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (...args) => {
|
||||
const stat = await originalStat(...args);
|
||||
if (args[0] === targetPath) {
|
||||
return cloneStatWithDev(stat, 0);
|
||||
}
|
||||
return stat;
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
|
||||
"Sandbox boundary checks failed",
|
||||
);
|
||||
expect(readlinkSpy).toHaveBeenCalled();
|
||||
expect(statSpy).toHaveBeenCalledWith(targetPath);
|
||||
} finally {
|
||||
statSpy.mockRestore();
|
||||
readlinkSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects fallback reads when an ancestor directory is swapped to a symlink", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
|
||||
const outsideDir = await makeTempDir("openclaw-openshell-outside-");
|
||||
await fs.mkdir(path.join(workspaceDir, "subdir"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "subdir", "secret.txt"), "inside", "utf8");
|
||||
await fs.writeFile(path.join(outsideDir, "secret.txt"), "outside", "utf8");
|
||||
|
||||
const backend = createMirrorBackendMock();
|
||||
const sandbox = createSandboxTestContext({
|
||||
overrides: {
|
||||
backendId: "openshell",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
containerWorkdir: "/sandbox",
|
||||
},
|
||||
});
|
||||
|
||||
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
|
||||
const bridge = createOpenShellFsBridge({ sandbox, backend });
|
||||
const originalOpen = fs.open.bind(fs);
|
||||
const targetPath = path.join(workspaceDir, "subdir", "secret.txt");
|
||||
let swapped = false;
|
||||
const openSpy = vi.spyOn(fs, "open").mockImplementation((async (...args: unknown[]) => {
|
||||
const filePath = args[0];
|
||||
if (!swapped && filePath === targetPath) {
|
||||
swapped = true;
|
||||
nodeFs.rmSync(path.join(workspaceDir, "subdir"), { recursive: true, force: true });
|
||||
nodeFs.symlinkSync(outsideDir, path.join(workspaceDir, "subdir"));
|
||||
}
|
||||
return await (originalOpen as (...delegated: unknown[]) => Promise<unknown>)(...args);
|
||||
}) as unknown as typeof fs.open);
|
||||
// Force the fallback verification path even on Linux so the ancestor-walk
|
||||
// guard is exercised directly.
|
||||
const readlinkSpy = vi
|
||||
.spyOn(fs, "readlink")
|
||||
.mockRejectedValue(new Error("fd path unavailable"));
|
||||
|
||||
try {
|
||||
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
|
||||
"Sandbox boundary checks failed",
|
||||
);
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
expect(readlinkSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
readlinkSpy.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects fallback reads of a symlinked leaf when O_NOFOLLOW is unavailable", async () => {
|
||||
it("rejects reads of a symlinked leaf", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
|
||||
const outsideDir = await makeTempDir("openclaw-openshell-outside-");
|
||||
await fs.mkdir(path.join(workspaceDir, "subdir"), { recursive: true });
|
||||
await fs.writeFile(path.join(outsideDir, "secret.txt"), "outside", "utf8");
|
||||
// The workspace contains a symlink as the FINAL path component pointing
|
||||
// out-of-root. On Windows `O_NOFOLLOW` is `undefined`, so `open` would
|
||||
// silently traverse the symlink to the outside file; the ancestor walk
|
||||
// must lstat the leaf in that case to fail closed.
|
||||
await fs.symlink(
|
||||
path.join(outsideDir, "secret.txt"),
|
||||
path.join(workspaceDir, "subdir", "secret.txt"),
|
||||
@@ -518,30 +373,12 @@ describe("openshell fs bridges", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { createOpenShellFsBridge, setReadOpenFlagsResolverForTest } =
|
||||
await import("./fs-bridge.js");
|
||||
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
|
||||
const bridge = createOpenShellFsBridge({ sandbox, backend });
|
||||
// Force the fallback path so the leaf-lstat guard is exercised.
|
||||
const readlinkSpy = vi
|
||||
.spyOn(fs, "readlink")
|
||||
.mockRejectedValue(new Error("fd path unavailable"));
|
||||
// Simulate a host that lacks `O_NOFOLLOW` (e.g. Windows) without touching
|
||||
// the non-configurable native `fs.constants` data property. The bridge
|
||||
// exposes a test-only seam for exactly this case.
|
||||
setReadOpenFlagsResolverForTest(() => ({
|
||||
flags: nodeFs.constants.O_RDONLY,
|
||||
supportsNoFollow: false,
|
||||
}));
|
||||
|
||||
try {
|
||||
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
|
||||
"Sandbox boundary checks failed",
|
||||
);
|
||||
expect(readlinkSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
setReadOpenFlagsResolverForTest(undefined);
|
||||
readlinkSpy.mockRestore();
|
||||
}
|
||||
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
|
||||
"Sandbox boundary checks failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects hardlinked files inside the sandbox root", async () => {
|
||||
|
||||
Reference in New Issue
Block a user