Outbound: scope core send media roots by agent (#17268)

Merged with gates skipped by maintainer request.

Prepared head SHA: 663ac49b3a
This commit is contained in:
Gustavo Madeira Santana
2026-02-15 11:43:02 -05:00
committed by GitHub
parent b4f16001aa
commit 9adcccadb1
7 changed files with 118 additions and 0 deletions

View File

@@ -128,6 +128,7 @@ Docs: https://docs.openclaw.ai
- Media/Security: allow local media reads from OpenClaw state `workspace/` and `sandboxes/` roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.
- Media/Security: harden local media allowlist bypasses by requiring an explicit `readFile` override when callers mark paths as validated, and reject filesystem-root `localRoots` entries. (#16739)
- Media/Security: allow outbound local media reads from the active agent workspace (including `workspace-<agentId>`) via agent-scoped local roots, avoiding broad global allowlisting of all per-agent workspaces. (#17136) Thanks @MisterGuy420.
- Outbound/Media: thread explicit `agentId` through core `sendMessage` direct-delivery path so agent-scoped local media roots apply even when mirror metadata is absent. (#17268) Thanks @gumadeiras.
- Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.
- Security/BlueBubbles: require explicit `mediaLocalRoots` allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.
- Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.

View File

@@ -102,6 +102,7 @@ describe("runMessageAction threading auto-injection", () => {
});
const call = mocks.executeSendAction.mock.calls[0]?.[0];
expect(call?.ctx?.agentId).toBe("main");
expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:111.222");
});

View File

@@ -509,6 +509,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
cfg,
channel,
params,
agentId,
accountId: accountId ?? undefined,
gateway,
toolContext: input.toolContext,

View File

@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getChannelPlugin: vi.fn(),
resolveOutboundTarget: vi.fn(),
deliverOutboundPayloads: vi.fn(),
}));
vi.mock("../../channels/plugins/index.js", () => ({
normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined,
getChannelPlugin: mocks.getChannelPlugin,
}));
vi.mock("./targets.js", () => ({
resolveOutboundTarget: mocks.resolveOutboundTarget,
}));
vi.mock("./deliver.js", () => ({
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
}));
import { sendMessage } from "./message.js";
describe("sendMessage", () => {
beforeEach(() => {
mocks.getChannelPlugin.mockReset();
mocks.resolveOutboundTarget.mockReset();
mocks.deliverOutboundPayloads.mockReset();
mocks.getChannelPlugin.mockReturnValue({
outbound: { deliveryMode: "direct" },
});
mocks.resolveOutboundTarget.mockImplementation(({ to }: { to: string }) => ({ ok: true, to }));
mocks.deliverOutboundPayloads.mockResolvedValue([{ channel: "mattermost", messageId: "m1" }]);
});
it("passes explicit agentId to outbound delivery for scoped media roots", async () => {
await sendMessage({
cfg: {},
channel: "mattermost",
to: "channel:town-square",
content: "hi",
agentId: "work",
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "work",
channel: "mattermost",
to: "channel:town-square",
}),
);
});
});

View File

@@ -31,6 +31,8 @@ export type MessageGatewayOptions = {
type MessageSendParams = {
to: string;
content: string;
/** Active agent id for per-agent outbound media root scoping. */
agentId?: string;
channel?: string;
mediaUrl?: string;
mediaUrls?: string[];
@@ -179,6 +181,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
cfg,
channel: outboundChannel,
to: resolvedTarget.to,
agentId: params.agentId,
accountId: params.accountId,
payloads: normalizedPayloads,
replyToId: params.replyToId,

View File

@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
dispatchChannelMessageAction: vi.fn(),
sendMessage: vi.fn(),
}));
vi.mock("../../channels/plugins/message-actions.js", () => ({
dispatchChannelMessageAction: (...args: unknown[]) => mocks.dispatchChannelMessageAction(...args),
}));
vi.mock("./message.js", () => ({
sendMessage: (...args: unknown[]) => mocks.sendMessage(...args),
sendPoll: vi.fn(),
}));
import { executeSendAction } from "./outbound-send-service.js";
describe("executeSendAction", () => {
beforeEach(() => {
mocks.dispatchChannelMessageAction.mockReset();
mocks.sendMessage.mockReset();
});
it("forwards ctx.agentId to sendMessage on core outbound path", async () => {
mocks.dispatchChannelMessageAction.mockResolvedValue(null);
mocks.sendMessage.mockResolvedValue({
channel: "discord",
to: "channel:123",
via: "direct",
mediaUrl: null,
});
await executeSendAction({
ctx: {
cfg: {},
channel: "discord",
params: {},
agentId: "work",
dryRun: false,
},
to: "channel:123",
message: "hello",
});
expect(mocks.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "work",
channel: "discord",
to: "channel:123",
content: "hello",
}),
);
});
});

View File

@@ -23,6 +23,8 @@ export type OutboundSendContext = {
cfg: OpenClawConfig;
channel: ChannelId;
params: Record<string, unknown>;
/** Active agent id for per-agent outbound media root scoping. */
agentId?: string;
accountId?: string | null;
gateway?: OutboundGatewayContext;
toolContext?: ChannelThreadingToolContext;
@@ -93,6 +95,7 @@ export async function executeSendAction(params: {
cfg: params.ctx.cfg,
to: params.to,
content: params.message,
agentId: params.ctx.agentId,
mediaUrl: params.mediaUrl || undefined,
mediaUrls: params.mediaUrls,
channel: params.ctx.channel || undefined,