CVE-2026-41389
Description
OpenClaw versions 2026.4.7 before 2026.4.15 fail to enforce local-root containment on tool-result media paths, allowing arbitrary local and UNC file access. Attackers can craft malicious tool-result media references to trigger host-side file reads or Windows network path access, potentially disclosing sensitive files or exposing credentials.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | >= 2026.4.7, < 2026.4.15 | 2026.4.15 |
Affected products
1Patches
352ef42302eadfix: tighten trusted tool media passthrough (#67303)
14 files changed · +345 −9
CHANGELOG.md+2 −0 modified@@ -8,6 +8,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/tools: anchor trusted local `MEDIA:` tool-result passthrough on the exact raw name of this run's registered built-in tools, and reject client tool definitions whose names normalize-collide with a built-in or with another client tool in the same request (`400 invalid_request_error` on both JSON and SSE paths), so a client-supplied tool named like a built-in can no longer inherit its local-media trust. (#67303) + ## 2026.4.15-beta.1 ### Changes
src/agents/pi-embedded-runner/run/attempt.ts+38 −1 modified@@ -29,6 +29,7 @@ import { resolveProviderTextTransforms, transformProviderSystemPrompt, } from "../../../plugins/provider-runtime.js"; +import { getPluginToolMeta } from "../../../plugins/tools.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js"; import { normalizeOptionalString } from "../../../shared/string-coerce.js"; @@ -86,7 +87,11 @@ import { import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js"; import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settings.js"; import { applyPiAutoCompactionGuard } from "../../pi-settings.js"; -import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js"; +import { + createClientToolNameConflictError, + findClientToolNameConflicts, + toClientToolDefinitions, +} from "../../pi-tool-definition-adapter.js"; import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js"; import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js"; import { describeProviderRequestRoutingSummary } from "../../provider-attribution.js"; @@ -962,6 +967,37 @@ export async function runEmbeddedAttempt( cfg: params.config, agentId: sessionAgentId, }); + // Exact raw names of every tool registered for this run, including + // bundled/plugin tools. Used as the raw-name set for the trusted local + // MEDIA: passthrough gate: a normalized alias is not sufficient — the + // emitted tool name must match an exact registration of this run. + const builtinToolNames = new Set( + effectiveTools.flatMap((tool) => { + const name = (tool.name ?? "").trim(); + return name ? [name] : []; + }), + ); + // Admission-time conflict check only against non-plugin core tools, to + // preserve prior behavior where client tools may coexist with unrelated + // plugin tool names. MEDIA passthrough is still gated by the raw-name + // set above, so a client tool that normalize-collides with a plugin + // tool cannot inherit the plugin's local-media trust. + const coreBuiltinToolNames = new Set( + effectiveTools.flatMap((tool) => { + const name = (tool.name ?? "").trim(); + if (!name || getPluginToolMeta(tool)) { + return []; + } + return [name]; + }), + ); + const clientToolNameConflicts = findClientToolNameConflicts({ + tools: clientTools ?? [], + existingToolNames: coreBuiltinToolNames, + }); + if (clientToolNameConflicts.length > 0) { + throw createClientToolNameConflictError(clientToolNameConflicts); + } const clientToolDefs = clientTools ? toClientToolDefinitions( clientTools, @@ -1529,6 +1565,7 @@ export async function runEmbeddedAttempt( sessionKey: sandboxSessionKey, sessionId: params.sessionId, agentId: sessionAgentId, + builtinToolNames, internalEvents: params.internalEvents, }), );
src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts+42 −0 modified@@ -10,6 +10,7 @@ function createMockContext(overrides?: { shouldEmitToolOutput?: boolean; onToolResult?: ReturnType<typeof vi.fn>; toolResultFormat?: "markdown" | "plain"; + builtinToolNames?: ReadonlySet<string>; }): EmbeddedPiSubscribeContext { const onToolResult = overrides?.onToolResult ?? vi.fn(); return { @@ -39,6 +40,7 @@ function createMockContext(overrides?: { deterministicApprovalPromptSent: false, }, log: { debug: vi.fn(), warn: vi.fn() }, + builtinToolNames: overrides?.builtinToolNames, shouldEmitToolResult: vi.fn(() => false), shouldEmitToolOutput: vi.fn(() => overrides?.shouldEmitToolOutput ?? false), emitToolSummary: vi.fn(), @@ -173,6 +175,46 @@ describe("handleToolExecutionEnd media emission", () => { expect(ctx.state.pendingToolMediaUrls).toEqual([]); }); + it("does NOT emit local media for case-variant collisions with trusted built-ins", async () => { + const ctx = createMockContext({ + shouldEmitToolOutput: false, + onToolResult: vi.fn(), + builtinToolNames: new Set(["web_search"]), + }); + + await handleToolExecutionEnd(ctx, { + type: "tool_execution_end", + toolName: "Web_Search", + toolCallId: "tc-1", + isError: false, + result: { + content: [{ type: "text", text: "MEDIA:/tmp/secret.png" }], + }, + }); + + expect(ctx.state.pendingToolMediaUrls).toEqual([]); + }); + + it("still emits remote media for case-variant collisions with trusted built-ins", async () => { + const ctx = createMockContext({ + shouldEmitToolOutput: false, + onToolResult: vi.fn(), + builtinToolNames: new Set(["web_search"]), + }); + + await handleToolExecutionEnd(ctx, { + type: "tool_execution_end", + toolName: "Web_Search", + toolCallId: "tc-1", + isError: false, + result: { + content: [{ type: "text", text: "MEDIA:https://example.com/file.png" }], + }, + }); + + expect(ctx.state.pendingToolMediaUrls).toEqual(["https://example.com/file.png"]); + }); + it("emits remote media for MCP-provenance results", async () => { const onToolResult = vi.fn(); const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult });
src/agents/pi-embedded-subscribe.handlers.tools.ts+21 −6 modified@@ -433,12 +433,13 @@ function readExecApprovalUnavailableDetails(result: unknown): { async function emitToolResultOutput(params: { ctx: ToolHandlerContext; toolName: string; + rawToolName: string; meta?: string; isToolError: boolean; result: unknown; sanitizedResult: unknown; }) { - const { ctx, toolName, meta, isToolError, result, sanitizedResult } = params; + const { ctx, toolName, rawToolName, meta, isToolError, result, sanitizedResult } = params; const hasStructuredMedia = result && typeof result === "object" && @@ -511,10 +512,10 @@ async function emitToolResultOutput(params: { ctx.shouldEmitToolOutput() || shouldEmitCompactToolOutput({ toolName, result, outputText }); if (shouldEmitOutput) { if (outputText) { - ctx.emitToolOutput(toolName, meta, outputText, result); + ctx.emitToolOutput(rawToolName, meta, outputText, result); if (ctx.params.toolResultFormat === "plain") { emittedToolOutputMediaUrls = await collectEmittedToolOutputMediaUrls( - toolName, + rawToolName, outputText, result, ); @@ -533,7 +534,12 @@ async function emitToolResultOutput(params: { if (!mediaReply) { return; } - const mediaUrls = filterToolResultMediaUrls(toolName, mediaReply.mediaUrls, result); + const mediaUrls = filterToolResultMediaUrls( + rawToolName, + mediaReply.mediaUrls, + result, + ctx.builtinToolNames, + ); const pendingMediaUrls = mediaReply.audioAsVoice || emittedToolOutputMediaUrls.length === 0 ? mediaUrls @@ -779,7 +785,8 @@ export async function handleToolExecutionEnd( result?: unknown; }, ) { - const toolName = normalizeToolName(evt.toolName); + const rawToolName = evt.toolName; + const toolName = normalizeToolName(rawToolName); const toolCallId = evt.toolCallId; const runId = ctx.params.runId; const isError = evt.isError; @@ -1099,7 +1106,15 @@ export async function handleToolExecutionEnd( `embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`, ); - await emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult }); + await emitToolResultOutput({ + ctx, + toolName, + rawToolName, + meta, + isToolError, + result, + sanitizedResult, + }); // Run after_tool_call plugin hook (fire-and-forget) const hookRunnerAfter = ctx.hookRunner ?? (await loadHookRunnerGlobal()).getGlobalHookRunner();
src/agents/pi-embedded-subscribe.handlers.types.ts+2 −0 modified@@ -93,6 +93,7 @@ export type EmbeddedPiSubscribeContext = { blockChunking?: BlockReplyChunking; blockChunker: EmbeddedBlockChunker | null; hookRunner?: HookRunner; + builtinToolNames?: ReadonlySet<string>; noteLastAssistant: (msg: AgentMessage) => void; shouldEmitToolResult: () => boolean; @@ -179,6 +180,7 @@ export type ToolHandlerContext = { state: ToolHandlerState; log: EmbeddedSubscribeLogger; hookRunner?: HookRunner; + builtinToolNames?: ReadonlySet<string>; flushBlockReplyBuffer: () => void | Promise<void>; shouldEmitToolResult: () => boolean; shouldEmitToolOutput: () => boolean;
src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts+29 −0 modified@@ -209,6 +209,35 @@ describe("subscribeEmbeddedPiSession", () => { expect(onPartialReply).not.toHaveBeenCalled(); }); + it("blocks local MEDIA urls from case-variant tool names in verbose output", async () => { + const onToolResult = vi.fn(); + const { emit } = createSubscribedHarness({ + runId: "run", + onToolResult, + verboseLevel: "full", + builtinToolNames: new Set(["web_search"]), + }); + + emitToolRun({ + emit, + toolName: "Web_Search", + toolCallId: "tool-1", + isError: false, + result: { + content: [{ type: "text", text: "Fetched page\nMEDIA:/tmp/secret.png" }], + }, + }); + + await vi.waitFor(() => { + expect(onToolResult).toHaveBeenCalled(); + }); + const payload = onToolResult.mock.calls.at(-1)?.[0] as + | { text?: string; mediaUrls?: string[] } + | undefined; + expect(payload?.text ?? "").toContain("Fetched page"); + expect(payload?.mediaUrls).toBeUndefined(); + }); + it("attaches media from internal completion events even when assistant omits MEDIA lines", async () => { const onBlockReply = vi.fn(); const { emit } = createSubscribedHarness({
src/agents/pi-embedded-subscribe.tools.media.test.ts+61 −0 modified@@ -277,6 +277,67 @@ describe("extractToolResultMediaPaths", () => { expect(isToolResultMediaTrusted("music_generate")).toBe(true); }); + it("blocks trusted-media aliases that are not exact registered built-ins", () => { + expect(filterToolResultMediaUrls("bash", ["/etc/passwd"], undefined, new Set(["exec"]))).toEqual( + [], + ); + expect( + filterToolResultMediaUrls( + "Web_Search", + ["/etc/passwd"], + undefined, + new Set(["web_search"]), + ), + ).toEqual([]); + }); + + it("keeps local media for exact registered built-in tool names", () => { + expect( + filterToolResultMediaUrls( + "web_search", + ["/tmp/screenshot.png"], + undefined, + new Set(["web_search"]), + ), + ).toEqual(["/tmp/screenshot.png"]); + }); + + it("keeps local media for bundled plugin tool names registered in this run", () => { + // music_generate is a bundled-plugin trusted tool; when the runner + // registers it for this run, its raw name must be allowed through the + // exact-name gate just like a core built-in. + expect( + filterToolResultMediaUrls( + "music_generate", + ["/tmp/song.mp3"], + undefined, + new Set(["music_generate"]), + ), + ).toEqual(["/tmp/song.mp3"]); + }); + + it("strips local media for plugin-name collisions when the plugin is not registered", () => { + expect( + filterToolResultMediaUrls( + "Music_Generate", + ["/etc/passwd"], + undefined, + new Set(["music_generate"]), + ), + ).toEqual([]); + }); + + it("still allows remote media for colliding aliases", () => { + expect( + filterToolResultMediaUrls( + "bash", + ["/etc/passwd", "https://example.com/file.png"], + undefined, + new Set(["exec"]), + ), + ).toEqual(["https://example.com/file.png"]); + }); + it("does not trust local MEDIA paths for MCP-provenance results", () => { expect( filterToolResultMediaUrls("browser", ["/tmp/screenshot.png"], {
src/agents/pi-embedded-subscribe.tools.ts+13 −0 modified@@ -211,11 +211,24 @@ export function filterToolResultMediaUrls( toolName: string | undefined, mediaUrls: string[], result?: unknown, + builtinToolNames?: ReadonlySet<string>, ): string[] { if (mediaUrls.length === 0) { return mediaUrls; } if (isToolResultMediaTrusted(toolName, result)) { + // When the current run provides its exact registered tool names (core + // built-ins plus bundled/trusted plugin tools), require the raw emitted + // tool name to match one of them before allowing local MEDIA: paths. + // This blocks normalized aliases and case-variant collisions such as + // "Bash" -> "bash" or "Web_Search" -> "web_search" from inheriting a + // registered tool's media trust. + if (builtinToolNames !== undefined) { + const registeredName = toolName?.trim(); + if (!registeredName || !builtinToolNames.has(registeredName)) { + return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim())); + } + } return mediaUrls; } return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim()));
src/agents/pi-embedded-subscribe.ts+7 −1 modified@@ -415,7 +415,12 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar return; } const { text: cleanedText, mediaUrls } = parseReplyDirectives(message); - const filteredMediaUrls = filterToolResultMediaUrls(toolName, mediaUrls ?? [], result); + const filteredMediaUrls = filterToolResultMediaUrls( + toolName, + mediaUrls ?? [], + result, + params.builtinToolNames, + ); if (!cleanedText && filteredMediaUrls.length === 0) { return; } @@ -724,6 +729,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar blockChunking, blockChunker, hookRunner: params.hookRunner, + builtinToolNames: params.builtinToolNames, noteLastAssistant, shouldEmitToolResult, shouldEmitToolOutput,
src/agents/pi-embedded-subscribe.types.ts+6 −0 modified@@ -39,5 +39,11 @@ export type SubscribeEmbeddedPiSessionParams = { sessionId?: string; /** Agent identity for hook context — resolved from session config in attempt.ts. */ agentId?: string; + /** + * Exact raw names of non-plugin OpenClaw tools registered for this run. + * When provided, MEDIA: passthrough requires an exact match instead of only + * a normalized-name collision with a trusted built-in. + */ + builtinToolNames?: ReadonlySet<string>; internalEvents?: AgentInternalEvent[]; };
src/agents/pi-tool-definition-adapter.test.ts+34 −1 modified@@ -2,7 +2,14 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { describe, expect, it } from "vitest"; import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js"; -import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js"; +import { + CLIENT_TOOL_NAME_CONFLICT_PREFIX, + createClientToolNameConflictError, + findClientToolNameConflicts, + isClientToolNameConflictError, + toClientToolDefinitions, + toToolDefinitions, +} from "./pi-tool-definition-adapter.js"; type ToolExecute = ReturnType<typeof toToolDefinitions>[number]["execute"]; const extensionContext = {} as Parameters<ToolExecute>[4]; @@ -177,3 +184,29 @@ describe("toClientToolDefinitions – param coercion", () => { expect(calledWith).toEqual({ action: "search", params: { q: "test", page: 1 } }); }); }); + +describe("client tool name conflict checks", () => { + it("detects collisions with existing built-in names after normalization", () => { + expect( + findClientToolNameConflicts({ + tools: [makeClientTool("Web_Search"), makeClientTool("exec")], + existingToolNames: ["web_search", "read"], + }), + ).toEqual(["Web_Search"]); + }); + + it("detects duplicate client tool names after normalization", () => { + expect( + findClientToolNameConflicts({ + tools: [makeClientTool("Weather"), makeClientTool("weather")], + }), + ).toEqual(["Weather", "weather"]); + }); + + it("wraps conflict errors with a stable prefix", () => { + const err = createClientToolNameConflictError(["exec", "Web_Search"]); + expect(err.message).toBe(`${CLIENT_TOOL_NAME_CONFLICT_PREFIX} exec, Web_Search`); + expect(isClientToolNameConflictError(err)).toBe(true); + expect(isClientToolNameConflictError(new Error("other failure"))).toBe(false); + }); +});
src/agents/pi-tool-definition-adapter.ts+44 −0 modified@@ -169,6 +169,50 @@ function splitToolExecuteArgs(args: ToolExecuteArgsAny): { }; } +export const CLIENT_TOOL_NAME_CONFLICT_PREFIX = "client tool name conflict:"; + +export function findClientToolNameConflicts(params: { + tools: ClientToolDefinition[]; + existingToolNames?: Iterable<string>; +}): string[] { + const existingNormalized = new Set<string>(); + for (const name of params.existingToolNames ?? []) { + const trimmed = name.trim(); + if (trimmed) { + existingNormalized.add(normalizeToolName(trimmed)); + } + } + + const conflicts = new Set<string>(); + const seenClientNames = new Map<string, string>(); + for (const tool of params.tools) { + const rawName = (tool.function?.name ?? "").trim(); + if (!rawName) { + continue; + } + const normalizedName = normalizeToolName(rawName); + if (existingNormalized.has(normalizedName)) { + conflicts.add(rawName); + } + const priorClientName = seenClientNames.get(normalizedName); + if (priorClientName) { + conflicts.add(priorClientName); + conflicts.add(rawName); + continue; + } + seenClientNames.set(normalizedName, rawName); + } + return Array.from(conflicts); +} + +export function createClientToolNameConflictError(conflicts: string[]): Error { + return new Error(`${CLIENT_TOOL_NAME_CONFLICT_PREFIX} ${conflicts.join(", ")}`); +} + +export function isClientToolNameConflictError(err: unknown): err is Error { + return err instanceof Error && err.message.startsWith(CLIENT_TOOL_NAME_CONFLICT_PREFIX); +} + export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] { return tools.map((tool) => { const name = tool.name || "tool";
src/gateway/openresponses-http.test.ts+16 −0 modified@@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; @@ -323,6 +324,21 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(agentCommand).toHaveBeenCalledTimes(0); await ensureResponseConsumed(resInvalidOverride); + agentCommand.mockClear(); + agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"])); + const resToolConflict = await postResponses(port, { + model: "openclaw", + input: "hi", + tools: WEATHER_TOOL, + }); + expect(resToolConflict.status).toBe(400); + const toolConflictJson = (await resToolConflict.json()) as { + error?: { code?: string; message?: string }; + }; + expect(toolConflictJson.error?.code).toBe("invalid_request_error"); + expect(toolConflictJson.error?.message).toBe("invalid tool configuration"); + await ensureResponseConsumed(resToolConflict); + mockAgentOnce([{ text: "hello" }]); const resUser = await postResponses(port, { user: "alice",
src/gateway/openresponses-http.ts+30 −0 modified@@ -10,6 +10,7 @@ import { createHash, randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { ImageContent } from "../agents/command/types.js"; import type { ClientToolDefinition } from "../agents/pi-embedded-runner/run/params.js"; +import { isClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; import { createDefaultDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.types.js"; import { agentCommandFromIngress } from "../commands/agent.js"; @@ -779,6 +780,17 @@ export async function handleOpenResponsesHttpRequest( return true; } logWarn(`openresponses: non-stream response failed: ${String(err)}`); + if (isClientToolNameConflictError(err)) { + const response = createResponseResource({ + id: responseId, + model, + status: "failed", + output: [], + error: { code: "invalid_request_error", message: "invalid tool configuration" }, + }); + sendJson(res, 400, response); + return true; + } const response = createResponseResource({ id: responseId, model, @@ -1101,6 +1113,24 @@ export async function handleOpenResponsesHttpRequest( logWarn(`openresponses: streaming response failed: ${String(err)}`); finalUsage = finalUsage ?? createEmptyUsage(); + if (isClientToolNameConflictError(err)) { + const errorResponse = createResponseResource({ + id: responseId, + model, + status: "failed", + output: [], + error: { code: "invalid_request_error", message: "invalid tool configuration" }, + usage: finalUsage, + }); + + writeSseEvent(res, { type: "response.failed", response: errorResponse }); + emitAgentEvent({ + runId: responseId, + stream: "lifecycle", + data: { phase: "error" }, + }); + return; + } const errorResponse = createResponseResource({ id: responseId, model,
1470de5d3e09fix(webchat): reject remote-host file:// URLs in media embedding path [AI-assisted] (#67293)
6 files changed · +138 −8
CHANGELOG.md+2 −0 modified@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(webchat): reject remote-host file:// URLs in media embedding path [AI-assisted]. (#67293) Thanks @pgondhi987. - fix(gateway): enforce localRoots containment on webchat audio embedding path [AI-assisted]. (#67298) Thanks @pgondhi987. - fix(matrix): block DM pairing-store entries from authorizing room control commands [AI-assisted]. (#67294) Thanks @pgondhi987. - Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559. @@ -31,6 +32,7 @@ Docs: https://docs.openclaw.ai - Docs/showcase: add a scannable hero, complete section jump links, and a responsive video grid for community examples. (#48493) Thanks @jchopard69. ### Fixes + - Security/approvals: redact secrets in exec approval prompts so inline approval review can no longer leak credential material in rendered prompt content. (#61077, #64790) - CLI/configure: re-read the persisted config hash after writes so config updates stop failing with stale-hash races. (#64188, #66528) - CLI/update: prune stale packaged `dist` chunks after npm upgrades and keep downgrade/verify inventory checks compat-safe so global upgrades stop failing on stale chunk imports. (#66959) Thanks @obviyus.
src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts+11 −0 modified@@ -107,6 +107,9 @@ const ttsMocks = vi.hoisted(() => ({ normalizeTtsAutoMode: vi.fn((value: unknown) => (typeof value === "string" ? value : undefined)), resolveTtsConfig: vi.fn((_cfg: OpenClawConfig) => ({ mode: "final" })), })); +const replyMediaPathMocks = vi.hoisted(() => ({ + createReplyMediaPathNormalizer: vi.fn(() => async (payload: ReplyPayload) => payload), +})); const threadInfoMocks = vi.hoisted(() => ({ parseSessionThreadInfo: vi.fn< (sessionKey: string | undefined) => { @@ -127,6 +130,7 @@ export { pluginConversationBindingMocks, sessionBindingMocks, sessionStoreMocks, + replyMediaPathMocks, threadInfoMocks, ttsMocks, }; @@ -263,6 +267,10 @@ vi.mock("../../tts/tts.js", () => ({ vi.mock("../../tts/tts.runtime.js", () => ({ maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params), })); +vi.mock("./reply-media-paths.runtime.js", () => ({ + createReplyMediaPathNormalizer: (params: unknown) => + replyMediaPathMocks.createReplyMediaPathNormalizer(params), +})); vi.mock("../../tts/status-config.js", () => ({ resolveStatusTtsSnapshot: () => ({ autoMode: "always", @@ -311,6 +319,9 @@ export function resetPluginTtsAndThreadMocks() { .mockReset() .mockImplementation((value: unknown) => (typeof value === "string" ? value : undefined)); ttsMocks.resolveTtsConfig.mockReset().mockReturnValue({ mode: "final" }); + replyMediaPathMocks.createReplyMediaPathNormalizer + .mockReset() + .mockReturnValue(async (payload: ReplyPayload) => payload); threadInfoMocks.parseSessionThreadInfo .mockReset() .mockImplementation(parseGenericThreadSessionInfo);
src/auto-reply/reply/dispatch-from-config.test.ts+58 −0 modified@@ -142,6 +142,9 @@ const ttsMocks = vi.hoisted(() => { resolveTtsConfig: vi.fn((_cfg: OpenClawConfig) => ({ mode: "final" })), }; }); +const replyMediaPathMocks = vi.hoisted(() => ({ + createReplyMediaPathNormalizer: vi.fn(() => async (payload: ReplyPayload) => payload), +})); const threadInfoMocks = vi.hoisted(() => ({ parseSessionThreadInfo: vi.fn< (sessionKey: string | undefined) => { @@ -326,6 +329,10 @@ vi.mock("../../tts/tts.js", () => ({ vi.mock("../../tts/tts.runtime.js", () => ({ maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params), })); +vi.mock("./reply-media-paths.runtime.js", () => ({ + createReplyMediaPathNormalizer: (params: unknown) => + replyMediaPathMocks.createReplyMediaPathNormalizer(params), +})); vi.mock("../../tts/status-config.js", () => ({ resolveStatusTtsSnapshot: () => ({ autoMode: "always", @@ -642,6 +649,10 @@ describe("dispatchReplyFromConfig", () => { ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final", }); + replyMediaPathMocks.createReplyMediaPathNormalizer.mockReset(); + replyMediaPathMocks.createReplyMediaPathNormalizer.mockReturnValue( + async (payload: ReplyPayload) => payload, + ); }); it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => { setNoAbort(); @@ -968,6 +979,12 @@ describe("dispatchReplyFromConfig", () => { await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(replyMediaPathMocks.createReplyMediaPathNormalizer).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + messageProvider: "telegram", + }), + ); expect(dispatcher.sendToolResult).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); expect(mocks.routeReply).toHaveBeenCalledTimes(1); @@ -1032,6 +1049,47 @@ describe("dispatchReplyFromConfig", () => { expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); + it("normalizes tool-result media before delivery and drops blocked file URLs", async () => { + setNoAbort(); + replyMediaPathMocks.createReplyMediaPathNormalizer.mockReturnValue( + async (payload: ReplyPayload) => ({ + ...payload, + mediaUrl: undefined, + mediaUrls: undefined, + }), + ); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "webchat", + Surface: "webchat", + ChatType: "group", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => { + await opts?.onToolResult?.({ + text: "NO_REPLY", + mediaUrls: ["file://attacker/share/probe.mp3"], + }); + return { text: "done" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(replyMediaPathMocks.createReplyMediaPathNormalizer).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + messageProvider: "webchat", + }), + ); + expect(dispatcher.sendToolResult).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" }); + }); + it("delivers tool summaries in forum topic sessions (group + IsForum)", async () => { setNoAbort(); const cfg = emptyConfig;
src/auto-reply/reply/dispatch-from-config.ts+41 −6 modified@@ -1,6 +1,10 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { isParentOwnedBackgroundAcpSession } from "../../acp/session-interaction-mode.js"; -import { resolveAgentConfig, resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { + resolveAgentConfig, + resolveAgentWorkspaceDir, + resolveSessionAgentId, +} from "../../agents/agent-scope.js"; import { resolveConversationBindingRecord, touchConversationBindingRecord, @@ -73,6 +77,8 @@ let getReplyFromConfigRuntimePromise: Promise< > | null = null; let abortRuntimePromise: Promise<typeof import("./abort.runtime.js")> | null = null; let ttsRuntimePromise: Promise<typeof import("../../tts/tts.runtime.js")> | null = null; +let replyMediaPathsRuntimePromise: Promise<typeof import("./reply-media-paths.runtime.js")> | null = + null; function loadRouteReplyRuntime() { routeReplyRuntimePromise ??= import("./route-reply.runtime.js"); @@ -94,6 +100,11 @@ function loadTtsRuntime() { return ttsRuntimePromise; } +function loadReplyMediaPathsRuntime() { + replyMediaPathsRuntimePromise ??= import("./reply-media-paths.runtime.js"); + return replyMediaPathsRuntimePromise; +} + async function maybeApplyTtsToReplyPayload( params: Parameters<Awaited<ReturnType<typeof loadTtsRuntime>>["maybeApplyTtsToPayload"]>[0], ) { @@ -336,6 +347,27 @@ export async function dispatchReplyFromConfig( }); const originatingTo = ctx.OriginatingTo; const ttsChannel = shouldRouteToOriginating ? originatingChannel : currentSurface; + const { createReplyMediaPathNormalizer } = await loadReplyMediaPathsRuntime(); + const normalizeReplyMediaPaths = createReplyMediaPathNormalizer({ + cfg, + sessionKey: acpDispatchSessionKey, + workspaceDir: resolveAgentWorkspaceDir(cfg, sessionAgentId), + messageProvider: ttsChannel, + accountId: ctx.AccountId, + groupId, + groupChannel: ctx.GroupChannel, + groupSpace: ctx.GroupSpace, + requesterSenderId: ctx.SenderId, + requesterSenderName: ctx.SenderName, + requesterSenderUsername: ctx.SenderUsername, + requesterSenderE164: ctx.SenderE164, + }); + const normalizeReplyMediaPayload = async (payload: ReplyPayload): Promise<ReplyPayload> => { + if (!resolveSendableOutboundReplyParts(payload).hasMedia) { + return payload; + } + return await normalizeReplyMediaPaths(payload); + }; const routeReplyToOriginating = async ( payload: ReplyPayload, @@ -610,7 +642,8 @@ export async function dispatchReplyFromConfig( inboundAudio, ttsAuto: sessionTtsAuto, }); - const result = await routeReplyToOriginating(ttsPayload); + const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload); + const result = await routeReplyToOriginating(normalizedPayload); if (result) { if (!result.ok) { logVerbose( @@ -623,7 +656,7 @@ export async function dispatchReplyFromConfig( }; } return { - queuedFinal: dispatcher.sendFinalReply(ttsPayload), + queuedFinal: dispatcher.sendFinalReply(normalizedPayload), routedFinalCount: 0, }; }; @@ -868,7 +901,8 @@ export async function dispatchReplyFromConfig( inboundAudio, ttsAuto: sessionTtsAuto, }); - const deliveryPayload = resolveToolDeliveryPayload(ttsPayload); + const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload); + const deliveryPayload = resolveToolDeliveryPayload(normalizedPayload); if (!deliveryPayload) { return; } @@ -947,10 +981,11 @@ export async function dispatchReplyFromConfig( inboundAudio, ttsAuto: sessionTtsAuto, }); + const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload); if (shouldRouteToOriginating) { - await sendPayloadAsync(ttsPayload, context?.abortSignal, false); + await sendPayloadAsync(normalizedPayload, context?.abortSignal, false); } else { - dispatcher.sendBlockReply(ttsPayload); + dispatcher.sendBlockReply(normalizedPayload); } }; return run();
src/gateway/server-methods/chat-webchat-media.test.ts+19 −0 modified@@ -86,6 +86,25 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => { expect((blocks[0] as { type?: string }).type).toBe("audio"); }); + it("drops tool-result file:// URLs with remote hosts before touching the filesystem", async () => { + const statSpy = vi.spyOn(fs, "statSync"); + const readSpy = vi.spyOn(fs, "readFileSync"); + + const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads([ + { + text: "MEDIA:file://attacker/share/probe.mp3", + mediaUrl: "file://attacker/share/probe.mp3", + }, + ]); + + expect(blocks).toHaveLength(0); + expect(statSpy).not.toHaveBeenCalled(); + expect(readSpy).not.toHaveBeenCalled(); + + statSpy.mockRestore(); + readSpy.mockRestore(); + }); + it("rejects a local audio file outside configured localRoots", async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-")); const allowedRoot = path.join(tmpDir, "allowed");
src/gateway/server-methods/chat-webchat-media.ts+7 −2 modified@@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import { assertLocalMediaAllowed, LocalMediaAccessError } from "../../media/local-media-access.js"; +import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../../infra/local-file-access.js"; import { isAudioFileName } from "../../media/mime.js"; import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; @@ -40,7 +40,7 @@ function resolveLocalMediaPathForEmbedding(raw: string): string | null { } if (trimmed.startsWith("file:")) { try { - const p = fileURLToPath(trimmed); + const p = safeFileURLToPath(trimmed); if (!path.isAbsolute(p)) { return null; } @@ -52,6 +52,11 @@ function resolveLocalMediaPathForEmbedding(raw: string): string | null { if (!path.isAbsolute(trimmed)) { return null; } + try { + assertNoWindowsNetworkPath(trimmed, "Local media path"); + } catch { + return null; + } return trimmed; }
6e58f1f9f54bfix(gateway): enforce localRoots containment on webchat audio embedding path [AI-assisted] (#67298)
4 files changed · +105 −26
CHANGELOG.md+1 −0 modified@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(gateway): enforce localRoots containment on webchat audio embedding path [AI-assisted]. (#67298) Thanks @pgondhi987. - fix(matrix): block DM pairing-store entries from authorizing room control commands [AI-assisted]. (#67294) Thanks @pgondhi987. - Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559. - Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.
src/gateway/server-methods/chat.ts+21 −6 modified@@ -13,6 +13,7 @@ import type { MsgContext } from "../../auto-reply/templating.js"; import { extractCanvasFromText } from "../../chat/canvas-render.js"; import { resolveSessionFilePath } from "../../config/sessions.js"; import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { isAudioFileName } from "../../media/mime.js"; import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js"; import { type SavedMedia, saveMediaBuffer } from "../../media/store.js"; @@ -121,10 +122,19 @@ function isMediaBearingPayload(payload: ReplyPayload): boolean { return false; } -function buildWebchatAudioOnlyAssistantMessage( +async function buildWebchatAudioOnlyAssistantMessage( payloads: ReplyPayload[], -): { content: Array<Record<string, unknown>>; transcriptText: string } | null { - const audioBlocks = buildWebchatAudioContentBlocksFromReplyPayloads(payloads); + options?: { + localRoots?: readonly string[]; + onLocalAudioAccessDenied?: (message: string) => void; + }, +): Promise<{ content: Array<Record<string, unknown>>; transcriptText: string } | null> { + const audioBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads(payloads, { + localRoots: options?.localRoots, + onLocalAudioAccessDenied: (err) => { + options?.onLocalAudioAccessDenied?.(formatForLog(err)); + }, + }); if (audioBlocks.length === 0) { return null; } @@ -2075,11 +2085,16 @@ export const chatHandlers: GatewayRequestHandlers = { savedImages: await persistedImagesPromise, }); }; - const appendWebchatAgentAudioTranscriptIfNeeded = (payload: ReplyPayload) => { + const appendWebchatAgentAudioTranscriptIfNeeded = async (payload: ReplyPayload) => { if (!agentRunStarted || appendedWebchatAgentAudio || !isMediaBearingPayload(payload)) { return; } - const audioMessage = buildWebchatAudioOnlyAssistantMessage([payload]); + const audioMessage = await buildWebchatAudioOnlyAssistantMessage([payload], { + localRoots: getAgentScopedMediaLocalRoots(cfg, agentId), + onLocalAudioAccessDenied: (message) => { + context.logGateway.warn(`webchat audio embedding denied local path: ${message}`); + }, + }); if (!audioMessage) { return; } @@ -2113,7 +2128,7 @@ export const chatHandlers: GatewayRequestHandlers = { case "block": case "final": deliveredReplies.push({ payload, kind: info.kind }); - appendWebchatAgentAudioTranscriptIfNeeded(payload); + await appendWebchatAgentAudioTranscriptIfNeeded(payload); break; case "tool": // Tool results that carry audio (e.g. the TTS tool) must be promoted
src/gateway/server-methods/chat-webchat-media.test.ts+64 −15 modified@@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { getDefaultLocalRoots } from "../../media/local-media-access.js"; import { buildWebchatAudioContentBlocksFromReplyPayloads } from "./chat-webchat-media.js"; describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => { @@ -15,12 +16,15 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => { tmpDir = undefined; }); - it("embeds a local audio file as a base64 gateway chat block", () => { + it("embeds a local audio file as a base64 gateway chat block when it is under localRoots", async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-")); const audioPath = path.join(tmpDir, "clip.mp3"); fs.writeFileSync(audioPath, Buffer.from([0xff, 0xfb, 0x90, 0x00])); - const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: audioPath }]); + const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads( + [{ mediaUrl: audioPath }], + { localRoots: [tmpDir] }, + ); expect(blocks).toHaveLength(1); const block = blocks[0] as { @@ -36,48 +40,90 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => { ); }); - it("skips remote URLs", () => { - const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([ + it("skips remote URLs", async () => { + const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads([ { mediaUrl: "https://example.com/a.mp3" }, ]); expect(blocks).toHaveLength(0); }); - it("skips non-audio local files", () => { + it("skips non-audio local files", async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-")); const imagePath = path.join(tmpDir, "clip.png"); fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); - const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: imagePath }]); + const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads( + [{ mediaUrl: imagePath }], + { localRoots: [tmpDir] }, + ); expect(blocks).toHaveLength(0); }); - it("dedupes repeated paths", () => { + it("dedupes repeated paths", async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-")); const audioPath = path.join(tmpDir, "clip.mp3"); fs.writeFileSync(audioPath, Buffer.from([0x00])); - const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([ - { mediaUrl: audioPath }, - { mediaUrl: audioPath }, - ]); + const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads( + [{ mediaUrl: audioPath }, { mediaUrl: audioPath }], + { localRoots: [tmpDir] }, + ); expect(blocks).toHaveLength(1); }); - it("embeds file:// URLs pointing at a local file", () => { + it("embeds file:// URLs pointing at a local file within localRoots", async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-")); const audioPath = path.join(tmpDir, "clip.mp3"); fs.writeFileSync(audioPath, Buffer.from([0x01])); const fileUrl = pathToFileURL(audioPath).href; - const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: fileUrl }]); + const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: fileUrl }], { + localRoots: [tmpDir], + }); + + expect(blocks).toHaveLength(1); + expect((blocks[0] as { type?: string }).type).toBe("audio"); + }); + + it("rejects a local audio file outside configured localRoots", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-")); + const allowedRoot = path.join(tmpDir, "allowed"); + const outsideRoot = path.join(tmpDir, "outside"); + fs.mkdirSync(allowedRoot, { recursive: true }); + fs.mkdirSync(outsideRoot, { recursive: true }); + const audioPath = path.join(outsideRoot, "clip.mp3"); + fs.writeFileSync(audioPath, Buffer.from([0x03])); + + const onLocalAudioAccessDenied = vi.fn(); + const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads( + [{ mediaUrl: audioPath }], + { + localRoots: [allowedRoot], + onLocalAudioAccessDenied, + }, + ); + + expect(blocks).toHaveLength(0); + expect(onLocalAudioAccessDenied).toHaveBeenCalledOnce(); + }); + + it("falls back to default localRoots when explicit roots are omitted", async () => { + const [defaultRoot] = getDefaultLocalRoots(); + expect(defaultRoot).toBeTruthy(); + + fs.mkdirSync(defaultRoot, { recursive: true }); + tmpDir = fs.mkdtempSync(path.join(defaultRoot, "openclaw-webchat-audio-default-")); + const audioPath = path.join(tmpDir, "clip.mp3"); + fs.writeFileSync(audioPath, Buffer.from([0x04])); + + const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: audioPath }]); expect(blocks).toHaveLength(1); expect((blocks[0] as { type?: string }).type).toBe("audio"); }); - it("does not read file contents when stat reports size over the cap", () => { + it("does not read file contents when stat reports size over the cap", async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-")); const audioPath = path.join(tmpDir, "huge.mp3"); fs.writeFileSync(audioPath, Buffer.from([0x02])); @@ -91,7 +137,10 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => { }); const readSpy = vi.spyOn(fs, "readFileSync"); - const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: audioPath }]); + const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads( + [{ mediaUrl: audioPath }], + { localRoots: [tmpDir] }, + ); expect(blocks).toHaveLength(0); expect(readSpy).not.toHaveBeenCalled();
src/gateway/server-methods/chat-webchat-media.ts+19 −5 modified@@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; +import { assertLocalMediaAllowed, LocalMediaAccessError } from "../../media/local-media-access.js"; import { isAudioFileName } from "../../media/mime.js"; import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; @@ -20,6 +21,11 @@ const MIME_BY_EXT: Record<string, string> = { ".webm": "audio/webm", }; +type WebchatAudioEmbeddingOptions = { + localRoots?: readonly string[]; + onLocalAudioAccessDenied?: (err: LocalMediaAccessError) => void; +}; + /** Map `mediaUrl` strings to an absolute filesystem path for local embedding (plain paths or `file:` URLs). */ function resolveLocalMediaPathForEmbedding(raw: string): string | null { const trimmed = raw.trim(); @@ -50,7 +56,10 @@ function resolveLocalMediaPathForEmbedding(raw: string): string | null { } /** Returns a readable local file path when it is a regular file and within the size cap (single stat before read). */ -function resolveLocalAudioFileForEmbedding(raw: string): string | null { +async function resolveLocalAudioFileForEmbedding( + raw: string, + options: WebchatAudioEmbeddingOptions | undefined, +): Promise<string | null> { const resolved = resolveLocalMediaPathForEmbedding(raw); if (!resolved) { return null; @@ -59,12 +68,16 @@ function resolveLocalAudioFileForEmbedding(raw: string): string | null { return null; } try { + await assertLocalMediaAllowed(resolved, options?.localRoots); const st = fs.statSync(resolved); if (!st.isFile() || st.size > MAX_WEBCHAT_AUDIO_BYTES) { return null; } return resolved; - } catch { + } catch (err) { + if (err instanceof LocalMediaAccessError) { + options?.onLocalAudioAccessDenied?.(err); + } return null; } } @@ -78,9 +91,10 @@ function mimeTypeForPath(filePath: string): string { * Build Control UI / transcript `content` blocks for local TTS (or other) audio files * referenced by slash-command / agent replies when the webchat path only had text aggregation. */ -export function buildWebchatAudioContentBlocksFromReplyPayloads( +export async function buildWebchatAudioContentBlocksFromReplyPayloads( payloads: ReplyPayload[], -): Array<Record<string, unknown>> { + options?: WebchatAudioEmbeddingOptions, +): Promise<Array<Record<string, unknown>>> { const seen = new Set<string>(); const blocks: Array<Record<string, unknown>> = []; for (const payload of payloads) { @@ -90,7 +104,7 @@ export function buildWebchatAudioContentBlocksFromReplyPayloads( if (!url) { continue; } - const resolved = resolveLocalAudioFileForEmbedding(url); + const resolved = await resolveLocalAudioFileForEmbedding(url, options); if (!resolved || seen.has(resolved)) { continue; }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
10- github.com/openclaw/openclaw/commit/1470de5d3e0970856d86cd99336bb8ada3fe87danvdPatchWEB
- github.com/openclaw/openclaw/commit/52ef42302ead9e183e6c8810e0a04ee4ef8ae9fcnvdPatchWEB
- github.com/openclaw/openclaw/commit/6e58f1f9f54bca1fea1268ec0ee4c01a2af03ddenvdPatchWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-mr34-9552-qr95nvdPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-mr34-9552-qr95ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-41389ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-arbitrary-file-read-via-unvalidated-tool-result-media-pathsnvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/pull/67293ghsaWEB
- github.com/openclaw/openclaw/pull/67298ghsaWEB
- github.com/openclaw/openclaw/pull/67303ghsaWEB
News mentions
0No linked articles in our index yet.