fix: harden sandbox media reads against TOCTOU escapes

This commit is contained in:
Peter Steinberger
2026-03-02 01:03:40 +00:00
parent 4320cde91d
commit c823a85302
12 changed files with 223 additions and 27 deletions

View File

@@ -4,7 +4,10 @@ import type { ImageContent } from "@mariozechner/pi-ai";
import { resolveUserPath } from "../../../utils.js";
import { loadWebMedia } from "../../../web/media.js";
import type { ImageSanitizationLimits } from "../../image-sanitization.js";
import { resolveSandboxedBridgeMediaPath } from "../../sandbox-media-paths.js";
import {
createSandboxBridgeReadFile,
resolveSandboxedBridgeMediaPath,
} from "../../sandbox-media-paths.js";
import { assertSandboxPath } from "../../sandbox-paths.js";
import type { SandboxFsBridge } from "../../sandbox/fs-bridge.js";
import { sanitizeImageBlocks } from "../../tool-images.js";
@@ -235,8 +238,7 @@ export async function loadImageFromRef(
? await loadWebMedia(targetPath, {
maxBytes: options.maxBytes,
sandboxValidated: true,
readFile: (filePath) =>
options.sandbox!.bridge.readFile({ filePath, cwd: options.sandbox!.root }),
readFile: createSandboxBridgeReadFile({ sandbox: options.sandbox }),
})
: await loadWebMedia(targetPath, options?.maxBytes);

View File

@@ -3,7 +3,12 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
import { SafeOpenError, openFileWithinRoot, writeFileWithinRoot } from "../infra/fs-safe.js";
import {
SafeOpenError,
openFileWithinRoot,
readFileWithinRoot,
writeFileWithinRoot,
} from "../infra/fs-safe.js";
import { detectMime } from "../media/mime.js";
import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js";
import type { ImageSanitizationLimits } from "./image-sanitization.js";
@@ -823,15 +828,11 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool
return {
readFile: async (absolutePath: string) => {
const relative = toRelativePathInRoot(root, absolutePath);
const opened = await openFileWithinRoot({
const safeRead = await readFileWithinRoot({
rootDir: root,
relativePath: relative,
});
try {
return await opened.handle.readFile();
} finally {
await opened.handle.close().catch(() => {});
}
return safeRead.buffer;
},
writeFile: async (absolutePath: string, content: string) => {
const relative = toRelativePathInRoot(root, absolutePath);

View File

@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { createSandboxBridgeReadFile } from "./sandbox-media-paths.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
describe("createSandboxBridgeReadFile", () => {
it("delegates reads through the sandbox bridge with sandbox root cwd", async () => {
const readFile = vi.fn(async () => Buffer.from("ok"));
const scopedRead = createSandboxBridgeReadFile({
sandbox: {
root: "/tmp/sandbox-root",
bridge: {
readFile,
} as unknown as SandboxFsBridge,
},
});
await expect(scopedRead("media/inbound/example.png")).resolves.toEqual(Buffer.from("ok"));
expect(readFile).toHaveBeenCalledWith({
filePath: "media/inbound/example.png",
cwd: "/tmp/sandbox-root",
});
});
});

View File

@@ -8,6 +8,16 @@ export type SandboxedBridgeMediaPathConfig = {
workspaceOnly?: boolean;
};
export function createSandboxBridgeReadFile(params: {
sandbox: Pick<SandboxedBridgeMediaPathConfig, "root" | "bridge">;
}): (filePath: string) => Promise<Buffer> {
return async (filePath: string) =>
await params.sandbox.bridge.readFile({
filePath,
cwd: params.sandbox.root,
});
}
export async function resolveSandboxedBridgeMediaPath(params: {
sandbox: SandboxedBridgeMediaPathConfig;
mediaPath: string;

View File

@@ -12,6 +12,7 @@ import { resolveConfiguredModelRef } from "../model-selection.js";
import { ensureOpenClawModelsJson } from "../models-config.js";
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
import {
createSandboxBridgeReadFile,
resolveSandboxedBridgeMediaPath,
type SandboxedBridgeMediaPathConfig,
} from "../sandbox-media-paths.js";
@@ -496,8 +497,7 @@ export function createImageTool(options?: {
? await loadWebMedia(resolvedPath ?? resolvedImage, {
maxBytes,
sandboxValidated: true,
readFile: (filePath) =>
sandboxConfig.bridge.readFile({ filePath, cwd: sandboxConfig.root }),
readFile: createSandboxBridgeReadFile({ sandbox: sandboxConfig }),
})
: await loadWebMedia(resolvedPath ?? resolvedImage, {
maxBytes,