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

* fix(media): honor sender policy for host media reads

* fix(media): clarify host read group policy gating

* fix(media): forward sender identity for outbound reads

* fix(media): propagate non-id sender fields through outbound session for e164/username/name policy matching

* fix(media): preserve requester provider for host read policy

* fix(media): forward full sender identity through followup and core send paths

* fix(media): forward requester session/account context through core send fallback

* fix(media): preserve account policy fallback for requester-scoped host reads

* chore(changelog): add outbound media sender-policy entry

* fix(media): align test call shape with production — omit messageProvider when sessionKey is set

Addresses P2 review: production call sites pass messageProvider: undefined
when sessionKey is present; tests should mirror that so regressions in
the precedence order are caught.

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
Agustin Rivera
2026-04-10 12:07:56 -07:00
committed by GitHub
parent 5df7771d0c
commit c949af9fab
18 changed files with 935 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

@@ -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: {},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 } : {}),