High severity7.7GHSA Advisory· Published May 5, 2026· Updated May 7, 2026
CVE-2026-42438
CVE-2026-42438
Description
OpenClaw versions 2026.4.9 before 2026.4.10 contain a sender policy bypass vulnerability in the outbound host-media attachment read helper that allows unauthorized local file disclosure. Attackers with denied read access via toolsBySender or group policy can trigger host-media attachment loading to bypass sender and group-scoped authorization boundaries and retrieve readable local files through the outbound media path.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | >= 2026.4.9, < 2026.4.10 | 2026.4.10 |
Affected products
2Patches
1c949af9fabf3fix(media): honor sender policy for host media reads (#64459)
18 files changed · +935 −16
CHANGELOG.md+1 −0 modified@@ -132,6 +132,7 @@ Docs: https://docs.openclaw.ai - Browser/security: default browser SSRF policy to strict mode so unconfigured installs block private-network navigation, and align external-content marker span mapping so ZWS-injected boundary spoofs are fully sanitized. (#63885) Thanks @eleqtrizit. - Browser/security: apply SSRF navigation policy to subframe document navigations so iframe-targeted private-network hops are blocked without quarantining the parent page. (#64371) Thanks @eleqtrizit. - Hooks/security: mark agent hook system events as untrusted and sanitize hook display names before cron metadata reuse. (#64372) Thanks @eleqtrizit. +- Media/security: honor sender-scoped `toolsBySender` policy for outbound host-media reads so denied senders cannot trigger host file disclosure via attachment hydration. (#64459) Thanks @eleqtrizit. ## 2026.4.9 ### Changes
src/auto-reply/reply/commands-reset-hooks.ts+1 −0 modified@@ -127,6 +127,7 @@ export async function emitResetCommandHooks(params: { to, sessionKey: params.sessionKey, accountId: params.ctx.AccountId, + requesterSenderId: params.command.senderId, threadId: params.ctx.MessageThreadId, cfg: params.cfg, });
src/auto-reply/reply/dispatch-acp-delivery.ts+5 −0 modified@@ -252,6 +252,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { message, }, sessionKey: params.ctx.SessionKey, + requesterAccountId: params.ctx.AccountId, }); state.routedCounts.tool += 1; return true; @@ -316,6 +317,10 @@ export function createAcpDispatchDeliveryCoordinator(params: { to: params.originatingTo, sessionKey: params.ctx.SessionKey, accountId: resolvedAccountId, + requesterSenderId: params.ctx.SenderId, + requesterSenderName: params.ctx.SenderName, + requesterSenderUsername: params.ctx.SenderUsername, + requesterSenderE164: params.ctx.SenderE164, threadId: params.ctx.MessageThreadId, cfg: params.cfg, });
src/auto-reply/reply/dispatch-from-config.ts+20 −0 modified@@ -351,6 +351,10 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, + requesterSenderId: ctx.SenderId, + requesterSenderName: ctx.SenderName, + requesterSenderUsername: ctx.SenderUsername, + requesterSenderE164: ctx.SenderE164, threadId: routeThreadId, cfg, abortSignal, @@ -374,6 +378,10 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, + requesterSenderId: ctx.SenderId, + requesterSenderName: ctx.SenderName, + requesterSenderUsername: ctx.SenderUsername, + requesterSenderE164: ctx.SenderE164, threadId: routeThreadId, cfg, isGroup, @@ -530,6 +538,10 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, + requesterSenderId: ctx.SenderId, + requesterSenderName: ctx.SenderName, + requesterSenderUsername: ctx.SenderUsername, + requesterSenderE164: ctx.SenderE164, threadId: routeThreadId, cfg, isGroup, @@ -587,6 +599,10 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, + requesterSenderId: ctx.SenderId, + requesterSenderName: ctx.SenderName, + requesterSenderUsername: ctx.SenderUsername, + requesterSenderE164: ctx.SenderE164, threadId: routeThreadId, cfg, isGroup, @@ -1012,6 +1028,10 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, + requesterSenderId: ctx.SenderId, + requesterSenderName: ctx.SenderName, + requesterSenderUsername: ctx.SenderUsername, + requesterSenderE164: ctx.SenderE164, threadId: routeThreadId, cfg, isGroup,
src/auto-reply/reply/followup-runner.ts+4 −0 modified@@ -102,6 +102,10 @@ export function createFollowupRunner(params: { to: originatingTo, sessionKey: queued.run.sessionKey, accountId: queued.originatingAccountId, + requesterSenderId: queued.run.senderId, + requesterSenderName: queued.run.senderName, + requesterSenderUsername: queued.run.senderUsername, + requesterSenderE164: queued.run.senderE164, threadId: queued.originatingThreadId, cfg: runtimeConfig, });
src/auto-reply/reply/route-reply.ts+12 −0 modified@@ -46,6 +46,14 @@ export type RouteReplyParams = { sessionKey?: string; /** Provider account id (multi-account). */ accountId?: string; + /** Originating sender id for sender-scoped outbound media policy. */ + requesterSenderId?: string; + /** Originating sender display name for name-keyed sender policy matching. */ + requesterSenderName?: string; + /** Originating sender username for username-keyed sender policy matching. */ + requesterSenderUsername?: string; + /** Originating sender E.164 phone number for e164-keyed sender policy matching. */ + requesterSenderE164?: string; /** Thread id for replies (Telegram topic id or Matrix thread event id). */ threadId?: string | number; /** Config for provider-specific settings. */ @@ -187,6 +195,10 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe cfg, agentId: resolvedAgentId, sessionKey: params.sessionKey, + requesterSenderId: params.requesterSenderId, + requesterSenderName: params.requesterSenderName, + requesterSenderUsername: params.requesterSenderUsername, + requesterSenderE164: params.requesterSenderE164, }); const results = await deliverOutboundPayloads({ cfg,
src/infra/outbound/deliver.test.ts+95 −0 modified@@ -7,6 +7,7 @@ import { whatsappOutbound, } from "../../../test/helpers/infra/deliver-test-outbounds.js"; import type { OpenClawConfig } from "../../config/config.js"; +import * as mediaCapabilityModule from "../../media/read-capability.js"; import { createHookRunner } from "../../plugins/hooks.js"; import { addTestHook } from "../../plugins/hooks.test-helpers.js"; import { createEmptyPluginRegistry } from "../../plugins/registry.js"; @@ -217,6 +218,100 @@ describe("deliverOutboundPayloads", () => { releasePinnedPluginChannelRegistry(); setActivePluginRegistry(emptyRegistry); }); + + it("keeps requester session channel authoritative for delivery media policy", async () => { + const resolveMediaAccessSpy = vi.spyOn( + mediaCapabilityModule, + "resolveAgentScopedOutboundMediaAccess", + ); + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hello" }], + deps: { whatsapp: sendWhatsApp }, + session: { + key: "agent:main:whatsapp:group:ops", + requesterSenderId: "attacker", + }, + }); + + expect(resolveMediaAccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:whatsapp:group:ops", + messageProvider: undefined, + requesterSenderId: "attacker", + }), + ); + resolveMediaAccessSpy.mockRestore(); + }); + + it("forwards all sender fields to media access for non-id policy matching", async () => { + const resolveMediaAccessSpy = vi.spyOn( + mediaCapabilityModule, + "resolveAgentScopedOutboundMediaAccess", + ); + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w2", toJid: "jid" }); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hello" }], + deps: { whatsapp: sendWhatsApp }, + session: { + key: "agent:main:whatsapp:group:ops", + requesterSenderId: "id:whatsapp:123", + requesterSenderName: "Alice", + requesterSenderUsername: "alice_u", + requesterSenderE164: "+15551234567", + }, + }); + + expect(resolveMediaAccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ + requesterSenderId: "id:whatsapp:123", + requesterSenderName: "Alice", + requesterSenderUsername: "alice_u", + requesterSenderE164: "+15551234567", + }), + ); + resolveMediaAccessSpy.mockRestore(); + }); + + it("uses requester account from session for delivery media policy", async () => { + const resolveMediaAccessSpy = vi.spyOn( + mediaCapabilityModule, + "resolveAgentScopedOutboundMediaAccess", + ); + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w3", toJid: "jid" }); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "whatsapp", + to: "+1555", + accountId: "destination-account", + payloads: [{ text: "hello" }], + deps: { whatsapp: sendWhatsApp }, + session: { + key: "agent:main:whatsapp:group:ops", + requesterAccountId: "source-account", + requesterSenderId: "attacker", + }, + }); + + expect(resolveMediaAccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:whatsapp:group:ops", + accountId: "source-account", + requesterSenderId: "attacker", + }), + ); + resolveMediaAccessSpy.mockRestore(); + }); + it("chunks direct adapter text and preserves delivery overrides across sends", async () => { const sendText = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({ channel: "matrix" as const,
src/infra/outbound/deliver.ts+7 −0 modified@@ -568,6 +568,13 @@ async function deliverOutboundPayloadsCore( cfg, agentId: params.session?.agentId ?? params.mirror?.agentId, mediaSources: collectPayloadMediaSources(payloads), + sessionKey: params.session?.key, + messageProvider: params.session?.key ? undefined : channel, + accountId: params.session?.requesterAccountId ?? accountId, + requesterSenderId: params.session?.requesterSenderId, + requesterSenderName: params.session?.requesterSenderName, + requesterSenderUsername: params.session?.requesterSenderUsername, + requesterSenderE164: params.session?.requesterSenderE164, }); const results: OutboundDeliveryResult[] = []; const handler = await createChannelHandler({
src/infra/outbound/message-action-runner.plugin-dispatch.test.ts+253 −0 modified@@ -301,6 +301,259 @@ describe("runMessageAction plugin dispatch", () => { }), ); }); + + it("uses requester session channel policy for host-media reads", async () => { + const handlePolicyCheckedAction = vi.fn(async ({ mediaAccess }) => + jsonResult({ + ok: true, + hasHostReadCapability: typeof mediaAccess?.readFile === "function", + }), + ); + const policyPlugin: ChannelPlugin = { + id: "feishu", + meta: { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu", + docsPath: "/channels/feishu", + blurb: "Feishu policy test plugin.", + }, + capabilities: { chatTypes: ["direct", "channel"], media: true }, + config: createAlwaysConfiguredPluginConfig(), + messaging: { + targetResolver: { + looksLikeId: () => true, + }, + }, + actions: { + describeMessageTool: () => ({ actions: ["send"] }), + supportsAction: ({ action }) => action === "send", + handleAction: handlePolicyCheckedAction, + }, + }; + + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "feishu", + source: "test", + plugin: policyPlugin, + }, + ]), + ); + + await runMessageAction({ + cfg: { + tools: { allow: ["read"] }, + channels: { + feishu: { + enabled: true, + }, + whatsapp: { + groups: { + ops: { + toolsBySender: { + "id:trusted-user": { + deny: ["read"], + }, + }, + }, + }, + }, + }, + } as OpenClawConfig, + action: "send", + params: { + channel: "feishu", + target: "oc_123", + message: "hello", + media: "/tmp/host.png", + }, + requesterSenderId: "trusted-user", + sessionKey: "agent:alpha:whatsapp:group:ops", + dryRun: false, + }); + + const pluginCall = handlePolicyCheckedAction.mock.calls[0]?.[0]; + expect(pluginCall?.mediaAccess).toBeDefined(); + expect(pluginCall?.mediaAccess?.readFile).toBeUndefined(); + }); + + it("uses requester account policy for host-media reads when destination account differs", async () => { + const handlePolicyCheckedAction = vi.fn(async ({ mediaAccess }) => + jsonResult({ + ok: true, + hasHostReadCapability: typeof mediaAccess?.readFile === "function", + }), + ); + const policyPlugin: ChannelPlugin = { + id: "feishu", + meta: { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu", + docsPath: "/channels/feishu", + blurb: "Feishu account policy test plugin.", + }, + capabilities: { chatTypes: ["direct", "channel"], media: true }, + config: createAlwaysConfiguredPluginConfig(), + messaging: { + targetResolver: { + looksLikeId: () => true, + }, + }, + actions: { + describeMessageTool: () => ({ actions: ["send"] }), + supportsAction: ({ action }) => action === "send", + handleAction: handlePolicyCheckedAction, + }, + }; + + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "feishu", + source: "test", + plugin: policyPlugin, + }, + ]), + ); + + await runMessageAction({ + cfg: { + tools: { allow: ["read"] }, + channels: { + feishu: { + enabled: true, + }, + whatsapp: { + accounts: { + source: { + groups: { + ops: { + toolsBySender: { + "id:trusted-user": { + deny: ["read"], + }, + }, + }, + }, + }, + destination: { + groups: { + ops: { + toolsBySender: { + "id:trusted-user": { + allow: ["read"], + }, + }, + }, + }, + }, + }, + }, + }, + } as OpenClawConfig, + action: "send", + params: { + channel: "feishu", + accountId: "destination", + target: "oc_123", + message: "hello", + media: "/tmp/host.png", + }, + requesterAccountId: "source", + requesterSenderId: "trusted-user", + sessionKey: "agent:alpha:whatsapp:group:ops", + dryRun: false, + }); + + const pluginCall = handlePolicyCheckedAction.mock.calls[0]?.[0]; + expect(pluginCall?.accountId).toBe("destination"); + expect(pluginCall?.mediaAccess).toBeDefined(); + expect(pluginCall?.mediaAccess?.readFile).toBeUndefined(); + }); + + it("falls back to the resolved account policy when requester account is unavailable", async () => { + const handlePolicyCheckedAction = vi.fn(async ({ mediaAccess }) => + jsonResult({ + ok: true, + hasHostReadCapability: typeof mediaAccess?.readFile === "function", + }), + ); + const policyPlugin: ChannelPlugin = { + id: "whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "WhatsApp account policy fallback test plugin.", + }, + capabilities: { chatTypes: ["direct", "channel"], media: true }, + config: createAlwaysConfiguredPluginConfig(), + messaging: { + targetResolver: { + looksLikeId: () => true, + }, + }, + actions: { + describeMessageTool: () => ({ actions: ["send"] }), + supportsAction: ({ action }) => action === "send", + handleAction: handlePolicyCheckedAction, + }, + }; + + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "whatsapp", + source: "test", + plugin: policyPlugin, + }, + ]), + ); + + await runMessageAction({ + cfg: { + tools: { allow: ["read"] }, + channels: { + whatsapp: { + enabled: true, + accounts: { + source: { + groups: { + ops: { + toolsBySender: { + "id:trusted-user": { + deny: ["read"], + }, + }, + }, + }, + }, + }, + }, + }, + } as OpenClawConfig, + action: "send", + params: { + channel: "whatsapp", + accountId: "source", + target: "group:ops", + message: "hello", + media: "/tmp/host.png", + }, + requesterSenderId: "trusted-user", + sessionKey: "agent:alpha:whatsapp:group:ops", + dryRun: false, + }); + + const pluginCall = handlePolicyCheckedAction.mock.calls[0]?.[0]; + expect(pluginCall?.accountId).toBe("source"); + expect(pluginCall?.mediaAccess).toBeDefined(); + expect(pluginCall?.mediaAccess?.readFile).toBeUndefined(); + }); }); describe("card-only send behavior", () => {
src/infra/outbound/message-action-runner.ts+8 −0 modified@@ -78,6 +78,7 @@ export type RunMessageActionParams = { action: ChannelMessageActionName; params: Record<string, unknown>; defaultAccountId?: string; + requesterAccountId?: string | null; requesterSenderId?: string | null; senderIsOwner?: boolean; sessionId?: string; @@ -543,6 +544,9 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi channel, params, agentId, + sessionKey: input.sessionKey, + requesterAccountId: input.requesterAccountId ?? undefined, + requesterSenderId: input.requesterSenderId ?? undefined, mediaAccess: ctx.mediaAccess, accountId: accountId ?? undefined, gateway, @@ -777,6 +781,10 @@ export async function runMessageAction( cfg, agentId: resolvedAgentId, mediaSources: collectActionMediaSourceHints(params), + sessionKey: input.sessionKey, + messageProvider: input.sessionKey ? undefined : channel, + accountId: input.sessionKey ? (input.requesterAccountId ?? accountId) : accountId, + requesterSenderId: input.requesterSenderId, }); const mediaPolicy = resolveAttachmentMediaPolicy({ sandboxRoot: input.sandboxRoot,
src/infra/outbound/message.test.ts+50 −0 modified@@ -89,6 +89,56 @@ describe("sendMessage", () => { ); }); + it("forwards requesterSenderId into the outbound delivery session", async () => { + await sendMessage({ + cfg: {}, + channel: "telegram", + to: "123456", + content: "hi", + requesterSenderId: "attacker", + mirror: { + sessionKey: "agent:main:telegram:group:ops", + }, + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + key: "agent:main:telegram:group:ops", + requesterSenderId: "attacker", + }), + }), + ); + }); + + it("uses requester session/account for outbound delivery policy context", async () => { + await sendMessage({ + cfg: {}, + channel: "telegram", + to: "123456", + content: "hi", + requesterSessionKey: "agent:main:whatsapp:group:ops", + requesterAccountId: "work", + requesterSenderId: "attacker", + mirror: { + sessionKey: "agent:main:telegram:dm:123456", + }, + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + key: "agent:main:whatsapp:group:ops", + requesterAccountId: "work", + requesterSenderId: "attacker", + }), + mirror: expect.objectContaining({ + sessionKey: "agent:main:telegram:dm:123456", + }), + }), + ); + }); + it("propagates the send idempotency key into mirrored transcript delivery", async () => { await sendMessage({ cfg: {},
src/infra/outbound/message.ts+9 −1 modified@@ -49,6 +49,12 @@ type MessageSendParams = { content: string; /** Active agent id for per-agent outbound media root scoping. */ agentId?: string; + /** Originating session key used for requester-scoped outbound media policy. */ + requesterSessionKey?: string; + /** Originating account id used for requester-scoped outbound media policy. */ + requesterAccountId?: string; + /** Originating sender id used for sender-scoped outbound media policy. */ + requesterSenderId?: string; channel?: string; mediaUrl?: string; mediaUrls?: string[]; @@ -265,7 +271,9 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen const outboundSession = buildOutboundSessionContext({ cfg, agentId: params.agentId, - sessionKey: params.mirror?.sessionKey, + sessionKey: params.requesterSessionKey ?? params.mirror?.sessionKey, + requesterAccountId: params.requesterAccountId ?? params.accountId, + requesterSenderId: params.requesterSenderId, }); const results = await deliverOutboundPayloads({ cfg,
src/infra/outbound/outbound-send-service.test.ts+199 −1 modified@@ -17,7 +17,13 @@ const createAgentScopedHostMediaReadFileMock = vi.hoisted(() => ); const resolveAgentScopedOutboundMediaAccessMock = vi.hoisted(() => vi.fn< - (params: { cfg: unknown; agentId?: string; mediaSources?: readonly string[] }) => { + (params: { + cfg: unknown; + agentId?: string; + mediaSources?: readonly string[]; + accountId?: string; + requesterSenderId?: string; + }) => { localRoots: string[]; readFile: (filePath: string) => Promise<Buffer>; } @@ -180,6 +186,198 @@ describe("executeSendAction", () => { ); }); + it("forwards requesterSenderId to sendMessage on core outbound path", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue(null); + mocks.sendMessage.mockResolvedValue({ + channel: "demo-outbound", + to: "channel:123", + via: "direct", + mediaUrl: null, + }); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "demo-outbound", + params: {}, + sessionKey: "agent:main:whatsapp:group:ops", + requesterSenderId: "attacker", + dryRun: false, + }, + to: "channel:123", + message: "hello", + }); + + expect(mocks.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + requesterSenderId: "attacker", + }), + ); + }); + + it("forwards requester session context to sendMessage on core outbound path", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue(null); + mocks.sendMessage.mockResolvedValue({ + channel: "demo-outbound", + to: "channel:123", + via: "direct", + mediaUrl: null, + }); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "demo-outbound", + params: {}, + sessionKey: "agent:main:whatsapp:group:ops", + requesterAccountId: "source-account", + requesterSenderId: "attacker", + accountId: "destination-account", + dryRun: false, + }, + to: "channel:123", + message: "hello", + }); + + expect(mocks.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + requesterSessionKey: "agent:main:whatsapp:group:ops", + requesterAccountId: "source-account", + requesterSenderId: "attacker", + accountId: "destination-account", + }), + ); + }); + + it("forwards requesterSenderId into outbound media access resolution", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "demo-outbound", + params: { media: "/tmp/host.png" }, + sessionKey: "agent:main:whatsapp:group:ops", + requesterSenderId: "attacker", + dryRun: false, + }, + to: "channel:123", + message: "hello", + }); + + expect(mocks.resolveAgentScopedOutboundMediaAccess).toHaveBeenCalledWith( + expect.objectContaining({ + requesterSenderId: "attacker", + }), + ); + }); + + it("keeps requester session channel authoritative for media policy", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "demo-outbound", + params: { media: "/tmp/host.png" }, + sessionKey: "agent:main:whatsapp:group:ops", + requesterSenderId: "attacker", + dryRun: false, + }, + to: "channel:123", + message: "hello", + }); + + expect(mocks.resolveAgentScopedOutboundMediaAccess).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:whatsapp:group:ops", + messageProvider: undefined, + }), + ); + }); + + it("uses requester account for media policy when session context is present", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "demo-outbound", + params: { media: "/tmp/host.png" }, + sessionKey: "agent:main:whatsapp:group:ops", + requesterAccountId: "source-account", + requesterSenderId: "attacker", + accountId: "destination-account", + dryRun: false, + }, + to: "channel:123", + message: "hello", + }); + + expect(mocks.resolveAgentScopedOutboundMediaAccess).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:whatsapp:group:ops", + accountId: "source-account", + }), + ); + }); + + it("falls back to destination account for media policy when requester account is missing", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "demo-outbound", + params: { media: "/tmp/host.png" }, + sessionKey: "agent:main:whatsapp:group:ops", + requesterSenderId: "attacker", + accountId: "destination-account", + dryRun: false, + }, + to: "channel:123", + message: "hello", + }); + + expect(mocks.resolveAgentScopedOutboundMediaAccess).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:whatsapp:group:ops", + accountId: "destination-account", + }), + ); + }); + + it("falls back to destination account when forwarding requester context to sendMessage", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue(null); + mocks.sendMessage.mockResolvedValue({ + channel: "demo-outbound", + to: "channel:123", + via: "direct", + mediaUrl: null, + }); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "demo-outbound", + params: {}, + sessionKey: "agent:main:whatsapp:group:ops", + requesterSenderId: "attacker", + accountId: "destination-account", + dryRun: false, + }, + to: "channel:123", + message: "hello", + }); + + expect(mocks.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + requesterSessionKey: "agent:main:whatsapp:group:ops", + requesterAccountId: "destination-account", + }), + ); + }); + it("uses plugin poll action when available", async () => { mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("poll-plugin"));
src/infra/outbound/outbound-send-service.ts+13 −0 modified@@ -28,6 +28,9 @@ export type OutboundSendContext = { params: Record<string, unknown>; /** Active agent id for per-agent outbound media root scoping. */ agentId?: string; + sessionKey?: string; + requesterAccountId?: string; + requesterSenderId?: string; mediaAccess?: OutboundMediaAccess; mediaReadFile?: OutboundMediaReadFile; accountId?: string | null; @@ -69,6 +72,13 @@ async function tryHandleWithPluginAction(params: { cfg: params.ctx.cfg, agentId: params.ctx.agentId ?? params.ctx.mirror?.agentId, mediaSources: collectActionMediaSources(params.ctx.params), + sessionKey: params.ctx.sessionKey, + messageProvider: params.ctx.sessionKey ? undefined : params.ctx.channel, + accountId: + (params.ctx.sessionKey + ? (params.ctx.requesterAccountId ?? params.ctx.accountId) + : params.ctx.accountId) ?? undefined, + requesterSenderId: params.ctx.requesterSenderId, mediaAccess: params.ctx.mediaAccess, mediaReadFile: params.ctx.mediaReadFile, }); @@ -145,6 +155,9 @@ export async function executeSendAction(params: { to: params.to, content: params.message, agentId: params.ctx.agentId, + requesterSessionKey: params.ctx.sessionKey, + requesterAccountId: params.ctx.requesterAccountId ?? params.ctx.accountId ?? undefined, + requesterSenderId: params.ctx.requesterSenderId, mediaUrl: params.mediaUrl || undefined, mediaUrls: params.mediaUrls, channel: params.ctx.channel || undefined,
src/infra/outbound/session-context.test.ts+51 −0 modified@@ -75,4 +75,55 @@ describe("buildOutboundSessionContext", () => { agentId: "explicit-agent", }); }); + + it("preserves a trimmed requester sender id when provided", () => { + expect( + buildOutboundSessionContext({ + cfg: {} as never, + requesterSenderId: " sender-123 ", + }), + ).toEqual({ + requesterSenderId: "sender-123", + }); + }); + + it("preserves a trimmed requester account id when provided", () => { + expect( + buildOutboundSessionContext({ + cfg: {} as never, + requesterAccountId: " work ", + }), + ).toEqual({ + requesterAccountId: "work", + }); + }); + + it("preserves trimmed non-id sender fields for e164/username/name policy matching", () => { + expect( + buildOutboundSessionContext({ + cfg: {} as never, + requesterSenderId: "id:telegram:123", + requesterSenderName: " Alice ", + requesterSenderUsername: " alice_u ", + requesterSenderE164: " +15551234567 ", + }), + ).toEqual({ + requesterSenderId: "id:telegram:123", + requesterSenderName: "Alice", + requesterSenderUsername: "alice_u", + requesterSenderE164: "+15551234567", + }); + }); + + it("returns undefined when all sender and session fields are blank", () => { + expect( + buildOutboundSessionContext({ + cfg: {} as never, + requesterSenderId: " ", + requesterSenderName: " ", + requesterSenderUsername: " ", + requesterSenderE164: " ", + }), + ).toBeUndefined(); + }); });
src/infra/outbound/session-context.ts+34 −1 modified@@ -7,24 +7,57 @@ export type OutboundSessionContext = { key?: string; /** Active agent id used for workspace-scoped media roots. */ agentId?: string; + /** Originating account id used for requester-scoped group policy resolution. */ + requesterAccountId?: string; + /** Originating sender id used for sender-scoped outbound media policy. */ + requesterSenderId?: string; + /** Originating sender display name for name-keyed sender policy matching. */ + requesterSenderName?: string; + /** Originating sender username for username-keyed sender policy matching. */ + requesterSenderUsername?: string; + /** Originating sender E.164 phone number for e164-keyed sender policy matching. */ + requesterSenderE164?: string; }; export function buildOutboundSessionContext(params: { cfg: OpenClawConfig; sessionKey?: string | null; agentId?: string | null; + requesterAccountId?: string | null; + requesterSenderId?: string | null; + requesterSenderName?: string | null; + requesterSenderUsername?: string | null; + requesterSenderE164?: string | null; }): OutboundSessionContext | undefined { const key = normalizeOptionalString(params.sessionKey); const explicitAgentId = normalizeOptionalString(params.agentId); + const requesterAccountId = normalizeOptionalString(params.requesterAccountId); + const requesterSenderId = normalizeOptionalString(params.requesterSenderId); + const requesterSenderName = normalizeOptionalString(params.requesterSenderName); + const requesterSenderUsername = normalizeOptionalString(params.requesterSenderUsername); + const requesterSenderE164 = normalizeOptionalString(params.requesterSenderE164); const derivedAgentId = key ? resolveSessionAgentId({ sessionKey: key, config: params.cfg }) : undefined; const agentId = explicitAgentId ?? derivedAgentId; - if (!key && !agentId) { + if ( + !key && + !agentId && + !requesterAccountId && + !requesterSenderId && + !requesterSenderName && + !requesterSenderUsername && + !requesterSenderE164 + ) { return undefined; } return { ...(key ? { key } : {}), ...(agentId ? { agentId } : {}), + ...(requesterAccountId ? { requesterAccountId } : {}), + ...(requesterSenderId ? { requesterSenderId } : {}), + ...(requesterSenderName ? { requesterSenderName } : {}), + ...(requesterSenderUsername ? { requesterSenderUsername } : {}), + ...(requesterSenderE164 ? { requesterSenderE164 } : {}), }; }
src/media/read-capability.test.ts+101 −0 modified@@ -21,4 +21,105 @@ describe("resolveAgentScopedOutboundMediaAccess", () => { expect(result).toMatchObject({ workspaceDir: "/tmp/explicit-workspace" }); }); + + it("does not enable host reads when sender group policy denies read", () => { + const cfg: OpenClawConfig = { + tools: { + allow: ["read"], + }, + channels: { + whatsapp: { + groups: { + ops: { + toolsBySender: { + "id:attacker": { + deny: ["read"], + }, + }, + }, + }, + }, + }, + }; + + const result = resolveAgentScopedOutboundMediaAccess({ + cfg, + sessionKey: "agent:main:whatsapp:group:ops", + // Production call sites set messageProvider: undefined when sessionKey is present; + // resolveGroupToolPolicy derives channel from the session key instead. + requesterSenderId: "attacker", + }); + + expect(result.readFile).toBeUndefined(); + }); + + it("keeps host reads enabled when sender group policy allows read", () => { + const cfg: OpenClawConfig = { + tools: { + allow: ["read"], + }, + channels: { + whatsapp: { + groups: { + ops: { + toolsBySender: { + "id:trusted-user": { + allow: ["read"], + }, + }, + }, + }, + }, + }, + }; + + const result = resolveAgentScopedOutboundMediaAccess({ + cfg, + sessionKey: "agent:main:whatsapp:group:ops", + requesterSenderId: "trusted-user", + }); + + expect(result.readFile).toBeTypeOf("function"); + }); + + it("keeps host reads enabled when no group policy applies", () => { + const result = resolveAgentScopedOutboundMediaAccess({ + cfg: { + tools: { + allow: ["read"], + }, + } as OpenClawConfig, + messageProvider: "whatsapp", + requesterSenderId: "trusted-user", + }); + + expect(result.readFile).toBeTypeOf("function"); + }); + + it("keeps host reads enabled for DM sender when no group context exists", () => { + const result = resolveAgentScopedOutboundMediaAccess({ + cfg: { + tools: { + allow: ["read"], + }, + channels: { + whatsapp: { + groups: { + ops: { + toolsBySender: { + "id:dm-sender": { + deny: ["read"], + }, + }, + }, + }, + }, + }, + } as OpenClawConfig, + messageProvider: "whatsapp", + requesterSenderId: "dm-sender", + }); + + expect(result.readFile).toBeTypeOf("function"); + }); });
src/media/read-capability.ts+72 −13 modified@@ -1,23 +1,70 @@ import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { resolvePathFromInput } from "../agents/path-policy.js"; +import { resolveGroupToolPolicy } from "../agents/pi-tools.policy.js"; import { resolveEffectiveToolFsRootExpansionAllowed } from "../agents/tool-fs-policy.js"; +import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; import { resolveWorkspaceRoot } from "../agents/workspace-dir.js"; import type { OpenClawConfig } from "../config/config.js"; import { readLocalFileSafely } from "../infra/fs-safe.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { OutboundMediaAccess, OutboundMediaReadFile } from "./load-options.js"; import { getAgentScopedMediaLocalRootsForSources } from "./local-roots.js"; -export function createAgentScopedHostMediaReadFile(params: { - cfg: OpenClawConfig; - agentId?: string; - workspaceDir?: string; -}): OutboundMediaReadFile | undefined { +type OutboundHostMediaPolicyContext = { + sessionKey?: string; + messageProvider?: string; + groupId?: string | null; + groupChannel?: string | null; + groupSpace?: string | null; + accountId?: string | null; + requesterSenderId?: string | null; + requesterSenderName?: string | null; + requesterSenderUsername?: string | null; + requesterSenderE164?: string | null; +}; + +function isAgentScopedHostMediaReadAllowed( + params: { + cfg: OpenClawConfig; + agentId?: string; + } & OutboundHostMediaPolicyContext, +): boolean { if ( !resolveEffectiveToolFsRootExpansionAllowed({ cfg: params.cfg, agentId: params.agentId, }) ) { + return false; + } + const groupPolicy = resolveGroupToolPolicy({ + config: params.cfg, + sessionKey: params.sessionKey, + messageProvider: params.messageProvider, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + accountId: params.accountId, + senderId: normalizeOptionalString(params.requesterSenderId), + senderName: normalizeOptionalString(params.requesterSenderName), + senderUsername: normalizeOptionalString(params.requesterSenderUsername), + senderE164: normalizeOptionalString(params.requesterSenderE164), + }); + // Sender/group policy only applies when a concrete group override exists. + if (groupPolicy && !isToolAllowedByPolicies("read", [groupPolicy])) { + return false; + } + return true; +} + +export function createAgentScopedHostMediaReadFile( + params: { + cfg: OpenClawConfig; + agentId?: string; + workspaceDir?: string; + } & OutboundHostMediaPolicyContext, +): OutboundMediaReadFile | undefined { + if (!isAgentScopedHostMediaReadAllowed(params)) { return undefined; } const inferredWorkspaceDir = @@ -30,14 +77,16 @@ export function createAgentScopedHostMediaReadFile(params: { }; } -export function resolveAgentScopedOutboundMediaAccess(params: { - cfg: OpenClawConfig; - agentId?: string; - mediaSources?: readonly string[]; - workspaceDir?: string; - mediaAccess?: OutboundMediaAccess; - mediaReadFile?: OutboundMediaReadFile; -}): OutboundMediaAccess { +export function resolveAgentScopedOutboundMediaAccess( + params: { + cfg: OpenClawConfig; + agentId?: string; + mediaSources?: readonly string[]; + workspaceDir?: string; + mediaAccess?: OutboundMediaAccess; + mediaReadFile?: OutboundMediaReadFile; + } & OutboundHostMediaPolicyContext, +): OutboundMediaAccess { const localRoots = params.mediaAccess?.localRoots ?? getAgentScopedMediaLocalRootsForSources({ @@ -56,6 +105,16 @@ export function resolveAgentScopedOutboundMediaAccess(params: { cfg: params.cfg, agentId: params.agentId, workspaceDir: resolvedWorkspaceDir, + sessionKey: params.sessionKey, + messageProvider: params.messageProvider, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + accountId: params.accountId, + requesterSenderId: params.requesterSenderId, + requesterSenderName: params.requesterSenderName, + requesterSenderUsername: params.requesterSenderUsername, + requesterSenderE164: params.requesterSenderE164, }); return { ...(localRoots?.length ? { localRoots } : {}),
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
5- github.com/openclaw/openclaw/commit/c949af9fabf3873b5b7c484090cb5f5ab6049a98nvdPatchWEB
- github.com/advisories/GHSA-jhpv-5j76-m56hghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-jhpv-5j76-m56hnvdVendor AdvisoryWEB
- www.vulncheck.com/advisories/openclaw-sender-policy-bypass-in-host-media-attachment-readsnvdThird Party Advisory
- github.com/openclaw/openclaw/pull/64459ghsaWEB
News mentions
0No linked articles in our index yet.