VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
>= 2026.4.9, < 2026.4.102026.4.10

Affected products

2
  • OpenClaw/OpenclawGHSA2 versions
    >= 2026.4.9, < 2026.4.10+ 1 more
    • (no CPE)range: >= 2026.4.9, < 2026.4.10
    • cpe:2.3:a:openclaw:openclaw:2026.4.9:*:*:*:*:node.js:*:*

Patches

1
c949af9fabf3

fix(media): honor sender policy for host media reads (#64459)

https://github.com/openclaw/openclawAgustin RiveraApr 10, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.