diff --git a/CHANGELOG.md b/CHANGELOG.md index 433ca60802e..b100a280818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. - Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. - Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. diff --git a/SECURITY.md b/SECURITY.md index 378eceaff91..fe6daa332ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -168,7 +168,7 @@ For threat model + hardening guidance (including `openclaw security audit --deep ### Tool filesystem hardening - `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory. -- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory. +- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory. - Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution. ### Web Interface Safety diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 330555d2ddf..c0d642b0e55 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -833,7 +833,7 @@ We may add a single `readOnlyMode` flag later to simplify this configuration. Additional hardening options: - `tools.exec.applyPatch.workspaceOnly: true` (default): ensures `apply_patch` cannot write/delete outside the workspace directory even when sandboxing is off. Set to `false` only if you intentionally want `apply_patch` to touch files outside the workspace. -- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory (useful if you allow absolute paths today and want a single guardrail). +- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory (useful if you allow absolute paths today and want a single guardrail). ### 5) Secure baseline (copy/paste) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 9406afae943..e05a21a5776 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -28,7 +28,7 @@ import { resolveUserPath } from "../../../utils.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { resolveOpenClawAgentDir } from "../../agent-paths.js"; -import { resolveSessionAgentIds } from "../../agent-scope.js"; +import { resolveAgentConfig, resolveSessionAgentIds } from "../../agent-scope.js"; import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js"; import { createCacheTrace } from "../../cache-trace.js"; @@ -363,6 +363,9 @@ export async function runEmbeddedAttempt( config: params.config, agentId: params.agentId, }); + const effectiveFsWorkspaceOnly = + (resolveAgentConfig(params.config ?? {}, sessionAgentId)?.tools?.fs?.workspaceOnly ?? + params.config?.tools?.fs?.workspaceOnly) === true; // Check if the model supports native image input const modelHasVision = params.model.input?.includes("image") ?? false; const toolsRaw = params.disableTools @@ -1087,6 +1090,7 @@ export async function runEmbeddedAttempt( historyMessages: activeSession.messages, maxBytes: MAX_IMAGE_BYTES, maxDimensionPx: resolveImageSanitizationLimits(params.config).maxDimensionPx, + workspaceOnly: effectiveFsWorkspaceOnly, // Enforce sandbox path restrictions when sandbox is enabled sandbox: sandbox?.enabled && sandbox?.fsBridge diff --git a/src/agents/pi-embedded-runner/run/images.test.ts b/src/agents/pi-embedded-runner/run/images.test.ts index d19ae3bd899..f9cb846da40 100644 --- a/src/agents/pi-embedded-runner/run/images.test.ts +++ b/src/agents/pi-embedded-runner/run/images.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js"; +import { createUnsafeMountedSandbox } from "../../test-helpers/unsafe-mounted-sandbox.js"; import { detectAndLoadPromptImages, detectImageReferences, @@ -275,4 +276,76 @@ describe("detectAndLoadPromptImages", () => { expect(result.images).toHaveLength(0); expect(result.historyImagesByIndex.size).toBe(0); }); + + it("blocks prompt image refs outside workspace when sandbox workspaceOnly is enabled", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-image-sandbox-")); + const sandboxRoot = path.join(stateDir, "sandbox"); + const agentRoot = path.join(stateDir, "agent"); + await fs.mkdir(sandboxRoot, { recursive: true }); + await fs.mkdir(agentRoot, { recursive: true }); + const pngB64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + await fs.writeFile(path.join(agentRoot, "secret.png"), Buffer.from(pngB64, "base64")); + const sandbox = createUnsafeMountedSandbox({ sandboxRoot, agentRoot }); + const bridge = sandbox.fsBridge; + if (!bridge) { + throw new Error("sandbox fs bridge missing"); + } + + try { + const result = await detectAndLoadPromptImages({ + prompt: "Inspect /agent/secret.png", + workspaceDir: sandboxRoot, + model: { input: ["text", "image"] }, + workspaceOnly: true, + sandbox: { root: sandbox.workspaceDir, bridge }, + }); + + expect(result.detectedRefs).toHaveLength(1); + expect(result.loadedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.images).toHaveLength(0); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("blocks history image refs outside workspace when sandbox workspaceOnly is enabled", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-image-sandbox-")); + const sandboxRoot = path.join(stateDir, "sandbox"); + const agentRoot = path.join(stateDir, "agent"); + await fs.mkdir(sandboxRoot, { recursive: true }); + await fs.mkdir(agentRoot, { recursive: true }); + const pngB64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + await fs.writeFile(path.join(agentRoot, "secret.png"), Buffer.from(pngB64, "base64")); + const sandbox = createUnsafeMountedSandbox({ sandboxRoot, agentRoot }); + const bridge = sandbox.fsBridge; + if (!bridge) { + throw new Error("sandbox fs bridge missing"); + } + + try { + const result = await detectAndLoadPromptImages({ + prompt: "No inline image in this turn.", + workspaceDir: sandboxRoot, + model: { input: ["text", "image"] }, + workspaceOnly: true, + historyMessages: [ + { + role: "user", + content: [{ type: "text", text: "Previous image /agent/secret.png" }], + }, + ], + sandbox: { root: sandbox.workspaceDir, bridge }, + }); + + expect(result.detectedRefs).toHaveLength(1); + expect(result.loadedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.historyImagesByIndex.size).toBe(0); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/pi-embedded-runner/run/images.ts b/src/agents/pi-embedded-runner/run/images.ts index c11f191e4f4..022950659e1 100644 --- a/src/agents/pi-embedded-runner/run/images.ts +++ b/src/agents/pi-embedded-runner/run/images.ts @@ -4,6 +4,7 @@ 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 { assertSandboxPath } from "../../sandbox-paths.js"; import type { SandboxFsBridge } from "../../sandbox/fs-bridge.js"; import { sanitizeImageBlocks } from "../../tool-images.js"; import { log } from "../logger.js"; @@ -181,6 +182,7 @@ export async function loadImageFromRef( workspaceDir: string, options?: { maxBytes?: number; + workspaceOnly?: boolean; sandbox?: { root: string; bridge: SandboxFsBridge }; }, ): Promise { @@ -211,6 +213,14 @@ export async function loadImageFromRef( } else if (!path.isAbsolute(targetPath)) { targetPath = path.resolve(workspaceDir, targetPath); } + if (options?.workspaceOnly) { + const root = options?.sandbox?.root ?? workspaceDir; + await assertSandboxPath({ + filePath: targetPath, + cwd: root, + root, + }); + } } // loadWebMedia handles local file paths (including file:// URLs) @@ -361,6 +371,7 @@ export async function detectAndLoadPromptImages(params: { historyMessages?: unknown[]; maxBytes?: number; maxDimensionPx?: number; + workspaceOnly?: boolean; sandbox?: { root: string; bridge: SandboxFsBridge }; }): Promise<{ /** Images for the current prompt (existingImages + detected in current prompt) */ @@ -422,6 +433,7 @@ export async function detectAndLoadPromptImages(params: { for (const ref of allRefs) { const image = await loadImageFromRef(ref, params.workspaceDir, { maxBytes: params.maxBytes, + workspaceOnly: params.workspaceOnly, sandbox: params.sandbox, }); if (image) {