From d24de1ec3b9aea0f5e8ad086215e5f74fee7331c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 17:56:48 +0000 Subject: [PATCH] feat(sandbox): allow image tool --- src/agents/clawdbot-tools.ts | 2 ++ src/agents/pi-tools.ts | 1 + src/agents/sandbox-agent-config.test.ts | 18 +++++++++++++++++ src/agents/sandbox.ts | 1 + src/agents/tools/image-tool.test.ts | 27 +++++++++++++++++++++++++ src/agents/tools/image-tool.ts | 22 +++++++++++++++++--- 6 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index c9852268b..e09be5c63 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -31,6 +31,7 @@ export function createClawdbotTools(options?: { agentProvider?: GatewayMessageProvider; agentAccountId?: string; agentDir?: string; + sandboxRoot?: string; workspaceDir?: string; sandboxed?: boolean; config?: ClawdbotConfig; @@ -46,6 +47,7 @@ export function createClawdbotTools(options?: { const imageTool = createImageTool({ config: options?.config, agentDir: options?.agentDir, + sandboxRoot: options?.sandboxRoot, }); const memorySearchTool = createMemorySearchTool({ config: options?.config, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 690566e6f..388c4179b 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -678,6 +678,7 @@ export function createClawdbotCodingTools(options?: { agentProvider: resolveGatewayMessageProvider(options?.messageProvider), agentAccountId: options?.agentAccountId, agentDir: options?.agentDir, + sandboxRoot, workspaceDir: options?.workspaceDir, sandboxed: !!sandbox, config: options?.config, diff --git a/src/agents/sandbox-agent-config.test.ts b/src/agents/sandbox-agent-config.test.ts index 0923c0b5e..ac56c97f9 100644 --- a/src/agents/sandbox-agent-config.test.ts +++ b/src/agents/sandbox-agent-config.test.ts @@ -486,4 +486,22 @@ describe("Agent-specific sandbox config", () => { const sandbox = resolveSandboxConfigForAgent(cfg, "main"); expect(sandbox.tools.allow).toContain("session_status"); }); + + it("includes image in default sandbox allowlist", async () => { + const { resolveSandboxConfigForAgent } = await import("./sandbox.js"); + + const cfg: ClawdbotConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "agent", + }, + }, + }, + }; + + const sandbox = resolveSandboxConfigForAgent(cfg, "main"); + expect(sandbox.tools.allow).toContain("image"); + }); }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index c0f94ea2d..4d9a61b60 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -172,6 +172,7 @@ const DEFAULT_TOOL_ALLOW = [ "write", "edit", "apply_patch", + "image", "sessions_list", "sessions_history", "sessions_send", diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 987ab5348..31c3869af 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -104,4 +104,31 @@ describe("image tool implicit imageModel config", () => { primary: "openai/gpt-5-mini", }); }); + + it("sandboxes image paths like the read tool", async () => { + const stateDir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-image-sandbox-"), + ); + const agentDir = path.join(stateDir, "agent"); + const sandboxRoot = path.join(stateDir, "sandbox"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.mkdir(sandboxRoot, { recursive: true }); + await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8"); + + vi.stubEnv("OPENAI_API_KEY", "openai-test"); + const cfg: ClawdbotConfig = { + agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } }, + }; + const tool = createImageTool({ config: cfg, agentDir, sandboxRoot }); + expect(tool).not.toBeNull(); + if (!tool) throw new Error("expected image tool"); + + await expect( + tool.execute("t1", { image: "https://example.com/a.png" }), + ).rejects.toThrow(/Sandboxed image tool does not allow remote URLs/i); + + await expect( + tool.execute("t2", { image: "../escape.png" }), + ).rejects.toThrow(/escapes sandbox root/i); + }); }); diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 9999e4bdf..c309695a4 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -24,6 +24,7 @@ import { runWithImageModelFallback } from "../model-fallback.js"; import { parseModelRef } from "../model-selection.js"; import { ensureClawdbotModelsJson } from "../models-config.js"; import { extractAssistantText } from "../pi-embedded-utils.js"; +import { assertSandboxPath } from "../sandbox-paths.js"; import type { AnyAgentTool } from "./common.js"; const DEFAULT_PROMPT = "Describe the image."; @@ -296,6 +297,7 @@ async function runImagePrompt(params: { export function createImageTool(options?: { config?: ClawdbotConfig; agentDir?: string; + sandboxRoot?: string; }): AnyAgentTool | null { const agentDir = options?.agentDir; if (!agentDir?.trim()) { @@ -337,9 +339,23 @@ export function createImageTool(options?: { typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined; const maxBytes = pickMaxBytes(options?.config, maxBytesMb); - const resolvedImage = imageRaw.startsWith("~") - ? resolveUserPath(imageRaw) - : imageRaw; + const sandboxRoot = options?.sandboxRoot?.trim(); + const isUrl = /^https?:\/\//i.test(imageRaw); + if (sandboxRoot && isUrl) { + throw new Error("Sandboxed image tool does not allow remote URLs."); + } + + const resolvedImage = sandboxRoot + ? ( + await assertSandboxPath({ + filePath: imageRaw, + cwd: sandboxRoot, + root: sandboxRoot, + }) + ).resolved + : imageRaw.startsWith("~") + ? resolveUserPath(imageRaw) + : imageRaw; const media = await loadWebMedia(resolvedImage, maxBytes); if (media.kind !== "image") { throw new Error(`Unsupported media type: ${media.kind}`);