fix: enforce workspaceOnly for native prompt image autoload

This commit is contained in:
Peter Steinberger 2026-02-24 14:47:22 +00:00
parent c3680c2277
commit 370d115549
6 changed files with 93 additions and 3 deletions

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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 });
}
});
});

View File

@ -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<ImageContent | null> {
@ -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) {