mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 03:40:42 +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
@@ -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