Security: harden tool media paths

This commit is contained in:
Shadow
2026-02-20 13:31:40 -06:00
parent 67edc7790f
commit c378439246
10 changed files with 120 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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