fix(sandbox): allow mkdirp boundary checks on existing directories (#31547)

This commit is contained in:
Peter Steinberger 2026-03-02 15:52:30 +00:00
parent 6135eb3353
commit dec2c9e74d
2 changed files with 53 additions and 19 deletions

View File

@ -7,12 +7,22 @@ vi.mock("./docker.js", () => ({
execDockerRaw: vi.fn(),
}));
vi.mock("../../infra/boundary-file-read.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/boundary-file-read.js")>();
return {
...actual,
openBoundaryFile: vi.fn(actual.openBoundaryFile),
};
});
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
import { execDockerRaw } from "./docker.js";
import { createSandboxFsBridge } from "./fs-bridge.js";
import { createSandboxTestContext } from "./test-fixtures.js";
import type { SandboxContext } from "./types.js";
const mockedExecDockerRaw = vi.mocked(execDockerRaw);
const mockedOpenBoundaryFile = vi.mocked(openBoundaryFile);
const DOCKER_SCRIPT_INDEX = 5;
const DOCKER_FIRST_SCRIPT_ARG_INDEX = 7;
@ -96,6 +106,7 @@ async function createHostEscapeFixture(stateDir: string) {
describe("sandbox fs bridge shell compatibility", () => {
beforeEach(() => {
mockedExecDockerRaw.mockClear();
mockedOpenBoundaryFile.mockClear();
installDockerReadMock();
});
@ -211,6 +222,34 @@ describe("sandbox fs bridge shell compatibility", () => {
});
});
it("allows mkdirp when boundary open reports io for an existing directory", async () => {
await withTempDir("openclaw-fs-bridge-mkdirp-io-", async (stateDir) => {
const workspaceDir = path.join(stateDir, "workspace");
const nestedDir = path.join(workspaceDir, "memory", "kemik");
await fs.mkdir(nestedDir, { recursive: true });
mockedOpenBoundaryFile.mockImplementationOnce(async () => ({
ok: false,
reason: "io",
error: Object.assign(new Error("EISDIR"), { code: "EISDIR" }),
}));
const bridge = createSandboxFsBridge({
sandbox: createSandbox({
workspaceDir,
agentWorkspaceDir: workspaceDir,
}),
});
await expect(bridge.mkdirp({ filePath: "memory/kemik" })).resolves.toBeUndefined();
const mkdirCall = findCallByScriptFragment('mkdir -p -- "$1"');
expect(mkdirCall).toBeDefined();
const mkdirPath = mkdirCall ? getDockerPathArg(mkdirCall[0]) : "";
expect(mkdirPath).toBe("/workspace/memory/kemik");
});
});
it("rejects mkdirp when target exists as a file", async () => {
await withTempDir("openclaw-fs-bridge-mkdirp-file-", async (stateDir) => {
const workspaceDir = path.join(stateDir, "workspace");

View File

@ -267,25 +267,12 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
});
if (!guarded.ok) {
if (guarded.reason !== "path") {
// mkdirp may legally target an already-existing directory. Keep a
// directory-only fallback so boundary checks remain strict for files
// while avoiding false negatives from file-oriented open validation.
if (options.allowedType === "directory") {
try {
const st = fs.statSync(target.hostPath);
if (!st.isDirectory()) {
throw new Error(
`Sandbox boundary checks failed; cannot ${options.action}: ${target.containerPath}`,
);
}
} catch {
throw guarded.error instanceof Error
? guarded.error
: new Error(
`Sandbox boundary checks failed; cannot ${options.action}: ${target.containerPath}`,
);
}
} else {
// Some platforms cannot open directories via openSync(O_RDONLY), even when
// the path is a valid in-boundary directory. Allow mkdirp to proceed in that
// narrow case by verifying the host path is an existing directory.
const canFallbackToDirectoryStat =
options.allowedType === "directory" && this.pathIsExistingDirectory(target.hostPath);
if (!canFallbackToDirectoryStat) {
throw guarded.error instanceof Error
? guarded.error
: new Error(
@ -314,6 +301,14 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
}
}
private pathIsExistingDirectory(hostPath: string): boolean {
try {
return fs.statSync(hostPath).isDirectory();
} catch {
return false;
}
}
private resolveMountByContainerPath(containerPath: string): SandboxFsMount | null {
const normalized = normalizeContainerPath(containerPath);
for (const mount of this.mountsByContainer) {