mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 15:18:28 +00:00
Security: harden tool media paths
This commit is contained in:
@@ -103,6 +103,42 @@ describe("handleToolExecutionEnd media emission", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT emit local media for untrusted tools", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult });
|
||||
|
||||
await handleToolExecutionEnd(ctx, {
|
||||
type: "tool_execution_end",
|
||||
toolName: "plugin_tool",
|
||||
toolCallId: "tc-1",
|
||||
isError: false,
|
||||
result: {
|
||||
content: [{ type: "text", text: "MEDIA:/tmp/secret.png" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(onToolResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits remote media for untrusted tools", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult });
|
||||
|
||||
await handleToolExecutionEnd(ctx, {
|
||||
type: "tool_execution_end",
|
||||
toolName: "plugin_tool",
|
||||
toolCallId: "tc-1",
|
||||
isError: false,
|
||||
result: {
|
||||
content: [{ type: "text", text: "MEDIA:https://example.com/file.png" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledWith({
|
||||
mediaUrls: ["https://example.com/file.png"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT emit media when verbose is full (emitToolOutput handles it)", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
const ctx = createMockContext({ shouldEmitToolOutput: true, onToolResult });
|
||||
|
||||
@@ -9,10 +9,11 @@ import type {
|
||||
ToolHandlerContext,
|
||||
} from "./pi-embedded-subscribe.handlers.types.js";
|
||||
import {
|
||||
extractMessagingToolSend,
|
||||
extractToolErrorMessage,
|
||||
extractToolResultMediaPaths,
|
||||
extractToolResultText,
|
||||
extractMessagingToolSend,
|
||||
filterToolResultMediaUrls,
|
||||
isToolResultError,
|
||||
sanitizeToolResult,
|
||||
} from "./pi-embedded-subscribe.tools.js";
|
||||
@@ -381,7 +382,7 @@ export async function handleToolExecutionEnd(
|
||||
// When shouldEmitToolOutput() is true, emitToolOutput already delivers media
|
||||
// via parseReplyDirectives (MEDIA: text extraction), so skip to avoid duplicates.
|
||||
if (ctx.params.onToolResult && !isToolError && !ctx.shouldEmitToolOutput()) {
|
||||
const mediaPaths = extractToolResultMediaPaths(result);
|
||||
const mediaPaths = filterToolResultMediaUrls(toolName, extractToolResultMediaPaths(result));
|
||||
if (mediaPaths.length > 0) {
|
||||
try {
|
||||
void ctx.params.onToolResult({ mediaUrls: mediaPaths });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MEDIA_TOKEN_RE } from "../media/parse.js";
|
||||
import { truncateUtf16Safe } from "../utils.js";
|
||||
import { collectTextContentBlocks } from "./content-blocks.js";
|
||||
import { type MessagingToolSend } from "./pi-embedded-messaging.js";
|
||||
import { normalizeToolName } from "./tool-policy.js";
|
||||
|
||||
const TOOL_RESULT_MAX_CHARS = 8000;
|
||||
const TOOL_ERROR_MAX_CHARS = 400;
|
||||
@@ -129,6 +130,58 @@ export function extractToolResultText(result: unknown): string | undefined {
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
// Core tool names that are allowed to emit local MEDIA: paths.
|
||||
// Plugin/MCP tools are intentionally excluded to prevent untrusted file reads.
|
||||
const TRUSTED_TOOL_RESULT_MEDIA = new Set([
|
||||
"agents_list",
|
||||
"apply_patch",
|
||||
"browser",
|
||||
"canvas",
|
||||
"cron",
|
||||
"edit",
|
||||
"exec",
|
||||
"gateway",
|
||||
"image",
|
||||
"memory_get",
|
||||
"memory_search",
|
||||
"message",
|
||||
"nodes",
|
||||
"process",
|
||||
"read",
|
||||
"session_status",
|
||||
"sessions_history",
|
||||
"sessions_list",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"subagents",
|
||||
"tts",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"write",
|
||||
]);
|
||||
const HTTP_URL_RE = /^https?:\/\//i;
|
||||
|
||||
export function isToolResultMediaTrusted(toolName?: string): boolean {
|
||||
if (!toolName) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeToolName(toolName);
|
||||
return TRUSTED_TOOL_RESULT_MEDIA.has(normalized);
|
||||
}
|
||||
|
||||
export function filterToolResultMediaUrls(
|
||||
toolName: string | undefined,
|
||||
mediaUrls: string[],
|
||||
): string[] {
|
||||
if (mediaUrls.length === 0) {
|
||||
return mediaUrls;
|
||||
}
|
||||
if (isToolResultMediaTrusted(toolName)) {
|
||||
return mediaUrls;
|
||||
}
|
||||
return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract media file paths from a tool result.
|
||||
*
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
EmbeddedPiSubscribeContext,
|
||||
EmbeddedPiSubscribeState,
|
||||
} from "./pi-embedded-subscribe.handlers.types.js";
|
||||
import { filterToolResultMediaUrls } from "./pi-embedded-subscribe.tools.js";
|
||||
import type { SubscribeEmbeddedPiSessionParams } from "./pi-embedded-subscribe.types.js";
|
||||
import { formatReasoningMessage, stripDowngradedToolCallText } from "./pi-embedded-utils.js";
|
||||
import { hasNonzeroUsage, normalizeUsage, type UsageLike } from "./usage.js";
|
||||
@@ -324,13 +325,14 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
markdown: useMarkdown,
|
||||
});
|
||||
const { text: cleanedText, mediaUrls } = parseReplyDirectives(agg);
|
||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) {
|
||||
const filteredMediaUrls = filterToolResultMediaUrls(toolName, mediaUrls ?? []);
|
||||
if (!cleanedText && filteredMediaUrls.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
void params.onToolResult({
|
||||
text: cleanedText,
|
||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||
mediaUrls: filteredMediaUrls.length ? filteredMediaUrls : undefined,
|
||||
});
|
||||
} catch {
|
||||
// ignore tool result delivery failures
|
||||
@@ -345,13 +347,14 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
});
|
||||
const message = `${agg}\n${formatToolOutputBlock(output)}`;
|
||||
const { text: cleanedText, mediaUrls } = parseReplyDirectives(message);
|
||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) {
|
||||
const filteredMediaUrls = filterToolResultMediaUrls(toolName, mediaUrls ?? []);
|
||||
if (!cleanedText && filteredMediaUrls.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
void params.onToolResult({
|
||||
text: cleanedText,
|
||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||
mediaUrls: filteredMediaUrls.length ? filteredMediaUrls : undefined,
|
||||
});
|
||||
} catch {
|
||||
// ignore tool result delivery failures
|
||||
|
||||
Reference in New Issue
Block a user