[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:
Peter Steinberger
2026-05-06 02:15:17 +01:00
committed by GitHub
parent 61481eb34f
commit 538605ff44
356 changed files with 4918 additions and 11913 deletions

View File

@@ -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 () => {