From 14baadda2c456f3cf749f1f97e8678746a34a7f4 Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Mon, 2 Mar 2026 16:18:02 +0000 Subject: [PATCH] fix(tools): honor fsPolicy.workspaceOnly in image/pdf tool localRoots PR #28822 fixed the Write/Edit tools to respect `tools.fs.workspaceOnly`, but the image and PDF tools still unconditionally include default local roots (`~/.openclaw/media`, `~/.openclaw/agents`, etc.) when computing the `localRoots` allowlist for non-sandbox mode. When `fsPolicy.workspaceOnly` is true, restrict `localRoots` to only the workspace directory so that files outside the workspace are rejected by `assertLocalMediaAllowed()`. Relates to #31716 Co-Authored-By: Claude Opus 4.6 --- src/agents/tools/image-tool.test.ts | 37 +++++++++++++++++++++++++++++ src/agents/tools/image-tool.ts | 5 +++- src/agents/tools/pdf-tool.test.ts | 28 ++++++++++++++++++++++ src/agents/tools/pdf-tool.ts | 5 +++- 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 97967ce36d6..c11799660ff 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -461,6 +461,43 @@ describe("image tool implicit imageModel config", () => { }); }); + it("respects fsPolicy.workspaceOnly for non-sandbox image paths", async () => { + await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => { + const fetch = stubMinimaxOkFetch(); + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); + try { + const cfg = createMinimaxImageConfig(); + + const tool = requireImageTool( + createImageTool({ + config: cfg, + agentDir, + workspaceDir, + fsPolicy: { workspaceOnly: true }, + }), + ); + + // File inside workspace is allowed. + await expectImageToolExecOk(tool, imagePath); + expect(fetch).toHaveBeenCalledTimes(1); + + // File outside workspace is rejected even without sandbox. + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-outside-")); + const outsideImage = path.join(outsideDir, "secret.png"); + await fs.writeFile(outsideImage, Buffer.from(ONE_PIXEL_PNG_B64, "base64")); + try { + await expect( + tool.execute("t2", { prompt: "Describe.", image: outsideImage }), + ).rejects.toThrow(/not under an allowed directory/i); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + }); + it("allows workspace images via createOpenClawCodingTools default workspace root", async () => { await withTempWorkspacePng(async ({ imagePath }) => { const fetch = stubMinimaxOkFetch(); diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index f7700e9bd30..55352eb373f 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -309,8 +309,11 @@ export function createImageTool(options?: { : "Analyze one or more images with the configured image model (agents.defaults.imageModel). Use image for a single path/URL, or images for multiple (up to 20). Provide a prompt describing what to analyze."; const localRoots = (() => { - const roots = getDefaultLocalRoots(); const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir); + if (options?.fsPolicy?.workspaceOnly) { + return workspaceDir ? [workspaceDir] : []; + } + const roots = getDefaultLocalRoots(); if (!workspaceDir) { return roots; } diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index 23640f66c95..c86d899ff9e 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -326,6 +326,34 @@ describe("createPdfTool", () => { }); }); + it("respects fsPolicy.workspaceOnly for non-sandbox pdf paths", async () => { + await withTempAgentDir(async (agentDir) => { + vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-ws-")); + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-out-")); + try { + const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL); + const tool = createPdfTool({ + config: cfg, + agentDir, + workspaceDir, + fsPolicy: { workspaceOnly: true }, + }); + expect(tool).not.toBeNull(); + + const outsidePdf = path.join(outsideDir, "secret.pdf"); + await fs.writeFile(outsidePdf, "%PDF-1.4 fake"); + + await expect(tool!.execute("t1", { prompt: "test", pdf: outsidePdf })).rejects.toThrow( + /not under an allowed directory/i, + ); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + }); + it("rejects unsupported scheme references", async () => { await withTempAgentDir(async (agentDir) => { vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); diff --git a/src/agents/tools/pdf-tool.ts b/src/agents/tools/pdf-tool.ts index 5c7c130b14e..5df6a95cae1 100644 --- a/src/agents/tools/pdf-tool.ts +++ b/src/agents/tools/pdf-tool.ts @@ -339,8 +339,11 @@ export function createPdfTool(options?: { : DEFAULT_MAX_PAGES; const localRoots = (() => { - const roots = getDefaultLocalRoots(); const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir); + if (options?.fsPolicy?.workspaceOnly) { + return workspaceDir ? [workspaceDir] : []; + } + const roots = getDefaultLocalRoots(); if (!workspaceDir) { return roots; }