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

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