Skip to content

Commit e25fa79

Browse files
authored
fix: gate Gateway message action requester provenance [AI] (#102031)
* fix: gate message action requester provenance * fix: preserve trusted gateway action context * fix: preserve message action effective scopes * chore: refresh gateway protocol swift model * fix: keep swift message action init additive * fix: avoid message action wire shape change * fix: downscope non-owner message action bridge * fix: keep cli message actions least privileged * fix: downscope message action runtime scope * fix: normalize message action bridge provenance
1 parent 3101aa3 commit e25fa79

5 files changed

Lines changed: 445 additions & 54 deletions

File tree

src/gateway/server-methods/send.test.ts

Lines changed: 179 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
77
import { jsonResult } from "../../agents/tools/common.js";
88
import type { ChannelPlugin } from "../../channels/plugins/types.js";
99
import { setActivePluginRegistry } from "../../plugins/runtime.js";
10+
import {
11+
getPluginRuntimeGatewayRequestScope,
12+
withPluginRuntimeGatewayRequestScope,
13+
} from "../../plugins/runtime/gateway-request-scope.js";
1014
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
1115
import { captureEnv, setTestEnvValue } from "../../test-utils/env.js";
16+
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
1217
import type { GatewayRequestContext } from "./types.js";
1318

1419
type ResolveOutboundTarget = typeof import("../../infra/outbound/targets.js").resolveOutboundTarget;
@@ -199,7 +204,12 @@ function createDeferred<T>() {
199204

200205
async function runMessageActionRequest(
201206
params: Record<string, unknown>,
202-
client?: { connect?: { scopes?: string[] } } | null,
207+
client?: {
208+
connect?: {
209+
scopes?: string[];
210+
client?: { id?: string; mode?: string };
211+
};
212+
} | null,
203213
) {
204214
const respond = vi.fn();
205215
await sendHandlers["message.action"]({
@@ -1605,29 +1615,40 @@ describe("gateway send mirroring", () => {
16051615
}),
16061616
);
16071617

1608-
const { respond } = await runMessageActionRequest({
1609-
channel: "whatsapp",
1610-
action: "react",
1611-
params: {
1612-
chatJid: "+15551234567",
1613-
messageId: "wamid.1",
1614-
emoji: "✅",
1618+
const { respond } = await runMessageActionRequest(
1619+
{
1620+
channel: "whatsapp",
1621+
action: "react",
1622+
params: {
1623+
chatJid: "+15551234567",
1624+
messageId: "wamid.1",
1625+
emoji: "✅",
1626+
},
1627+
requesterAccountId: "default",
1628+
requesterSenderId: "trusted-user",
1629+
inboundTurnKind: "room_event",
1630+
toolContext: {
1631+
currentMessagingTarget: "user:15551234567",
1632+
currentGraphChannelId: "graph:team/chan",
1633+
currentChannelProvider: "whatsapp",
1634+
currentMessageId: "wamid.1",
1635+
replyToMode: "first",
1636+
hasRepliedRef: { value: true },
1637+
sameChannelThreadRequired: true,
1638+
skipCrossContextDecoration: true,
1639+
},
1640+
idempotencyKey: "idem-message-action",
16151641
},
1616-
requesterAccountId: "default",
1617-
requesterSenderId: "trusted-user",
1618-
inboundTurnKind: "room_event",
1619-
toolContext: {
1620-
currentMessagingTarget: "user:15551234567",
1621-
currentGraphChannelId: "graph:team/chan",
1622-
currentChannelProvider: "whatsapp",
1623-
currentMessageId: "wamid.1",
1624-
replyToMode: "first",
1625-
hasRepliedRef: { value: true },
1626-
sameChannelThreadRequired: true,
1627-
skipCrossContextDecoration: true,
1642+
{
1643+
connect: {
1644+
scopes: ["operator.admin"],
1645+
client: {
1646+
id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
1647+
mode: GATEWAY_CLIENT_MODES.BACKEND,
1648+
},
1649+
},
16281650
},
1629-
idempotencyKey: "idem-message-action",
1630-
});
1651+
);
16311652

16321653
expect(respond).toHaveBeenCalledWith(
16331654
true,
@@ -1651,6 +1672,142 @@ describe("gateway send mirroring", () => {
16511672
expect.objectContaining({
16521673
inboundEventKind: "room_event",
16531674
requesterAccountId: "default",
1675+
requesterSenderId: "trusted-user",
1676+
gatewayClientScopes: ["operator.write"],
1677+
}),
1678+
);
1679+
});
1680+
1681+
it("drops caller-supplied requester identity from operator.write message actions", async () => {
1682+
const reactPlugin: ChannelPlugin = {
1683+
id: "whatsapp",
1684+
meta: {
1685+
id: "whatsapp",
1686+
label: "WhatsApp",
1687+
selectionLabel: "WhatsApp",
1688+
docsPath: "/channels/whatsapp",
1689+
blurb: "WhatsApp action dispatch test plugin.",
1690+
},
1691+
capabilities: { chatTypes: ["direct"], reactions: true },
1692+
config: {
1693+
listAccountIds: () => ["default"],
1694+
resolveAccount: () => ({ enabled: true }),
1695+
isConfigured: () => true,
1696+
},
1697+
actions: {
1698+
describeMessageTool: () => ({ actions: ["react"] }),
1699+
supportsAction: ({ action }) => action === "react",
1700+
handleAction: async () => jsonResult({ ok: true }),
1701+
},
1702+
};
1703+
mocks.getChannelPlugin.mockReturnValue(reactPlugin);
1704+
setActivePluginRegistry(
1705+
createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]),
1706+
"send-test-message-action-untrusted-requester",
1707+
);
1708+
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(jsonResult({ ok: true }));
1709+
1710+
const { respond } = await runMessageActionRequest(
1711+
{
1712+
channel: "whatsapp",
1713+
action: "react",
1714+
params: {
1715+
chatJid: "+15551234567",
1716+
messageId: "wamid.1",
1717+
emoji: "✅",
1718+
},
1719+
requesterAccountId: "default",
1720+
requesterSenderId: "spoofed-admin-user",
1721+
senderIsOwner: true,
1722+
idempotencyKey: "idem-message-action-untrusted-requester",
1723+
},
1724+
{ connect: { scopes: ["operator.write"] } },
1725+
);
1726+
1727+
expect(firstRespondCall(respond)[0]).toBe(true);
1728+
expect(lastDispatchChannelMessageActionCall()).toEqual(
1729+
expect.objectContaining({
1730+
requesterAccountId: undefined,
1731+
requesterSenderId: undefined,
1732+
senderIsOwner: false,
1733+
gatewayClientScopes: ["operator.write"],
1734+
}),
1735+
);
1736+
});
1737+
1738+
it("down-scopes backend bridge actions that carry explicit non-owner provenance", async () => {
1739+
const reactPlugin: ChannelPlugin = {
1740+
id: "whatsapp",
1741+
meta: {
1742+
id: "whatsapp",
1743+
label: "WhatsApp",
1744+
selectionLabel: "WhatsApp",
1745+
docsPath: "/channels/whatsapp",
1746+
blurb: "WhatsApp action dispatch test plugin.",
1747+
},
1748+
capabilities: { chatTypes: ["direct"], reactions: true },
1749+
config: {
1750+
listAccountIds: () => ["default"],
1751+
resolveAccount: () => ({ enabled: true }),
1752+
isConfigured: () => true,
1753+
},
1754+
actions: {
1755+
describeMessageTool: () => ({ actions: ["react"] }),
1756+
supportsAction: ({ action }) => action === "react",
1757+
handleAction: async () => jsonResult({ ok: true }),
1758+
},
1759+
};
1760+
mocks.getChannelPlugin.mockReturnValue(reactPlugin);
1761+
setActivePluginRegistry(
1762+
createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]),
1763+
"send-test-message-action-backend-non-owner",
1764+
);
1765+
let scopedGatewayClientScopes: readonly string[] | undefined;
1766+
mocks.dispatchChannelMessageAction.mockImplementationOnce(async () => {
1767+
scopedGatewayClientScopes =
1768+
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes ?? [];
1769+
return jsonResult({ ok: true });
1770+
});
1771+
1772+
const backendClient = {
1773+
connect: {
1774+
scopes: ["operator.admin"],
1775+
client: {
1776+
id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
1777+
mode: GATEWAY_CLIENT_MODES.BACKEND,
1778+
},
1779+
},
1780+
};
1781+
const { respond } = await withPluginRuntimeGatewayRequestScope(
1782+
{
1783+
client: backendClient as never,
1784+
isWebchatConnect: () => false,
1785+
},
1786+
async () =>
1787+
await runMessageActionRequest(
1788+
{
1789+
channel: "whatsapp",
1790+
action: "react",
1791+
params: {
1792+
chatJid: "+15551234567",
1793+
messageId: "wamid.1",
1794+
emoji: "✅",
1795+
},
1796+
senderIsOwner: false,
1797+
idempotencyKey: "idem-message-action-backend-non-owner",
1798+
},
1799+
backendClient,
1800+
),
1801+
);
1802+
1803+
expect(firstRespondCall(respond)[0]).toBe(true);
1804+
expect(scopedGatewayClientScopes).toEqual(["operator.write"]);
1805+
expect(lastDispatchChannelMessageActionCall()).toEqual(
1806+
expect.objectContaining({
1807+
requesterAccountId: undefined,
1808+
requesterSenderId: undefined,
1809+
senderIsOwner: false,
1810+
gatewayClientScopes: ["operator.write"],
16541811
}),
16551812
);
16561813
});

src/gateway/server-methods/send.ts

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,22 @@ import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.j
4444
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
4545
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
4646
import { extractToolPayload } from "../../plugin-sdk/tool-payload.js";
47+
import {
48+
getPluginRuntimeGatewayRequestScope,
49+
withPluginRuntimeGatewayRequestScope,
50+
} from "../../plugins/runtime/gateway-request-scope.js";
4751
import { normalizePollInput } from "../../polls.js";
4852
import {
4953
normalizeSessionKeyPreservingOpaquePeerIds,
5054
parseThreadSessionSuffix,
5155
} from "../../sessions/session-key-utils.js";
52-
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
53-
import { ADMIN_SCOPE } from "../operator-scopes.js";
56+
import {
57+
GATEWAY_CLIENT_MODES,
58+
GATEWAY_CLIENT_NAMES,
59+
INTERNAL_MESSAGE_CHANNEL,
60+
normalizeMessageChannel,
61+
} from "../../utils/message-channel.js";
62+
import { ADMIN_SCOPE, WRITE_SCOPE } from "../operator-scopes.js";
5463
import { resolveGatewayPluginConfig } from "../runtime-plugin-config.js";
5564
import { formatForLog } from "../ws-log.js";
5665
import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
@@ -67,6 +76,26 @@ const inflightByContext = new WeakMap<
6776
Map<string, Promise<InflightResult>>
6877
>();
6978

79+
const TRUSTED_MESSAGE_ACTION_BRIDGE_SCOPES = [WRITE_SCOPE];
80+
81+
async function withMessageActionGatewayClientScopes<T>(
82+
scopes: readonly string[],
83+
run: () => Promise<T>,
84+
): Promise<T> {
85+
const current = getPluginRuntimeGatewayRequestScope();
86+
if (!current?.client?.connect) {
87+
return await run();
88+
}
89+
const client = {
90+
...current.client,
91+
connect: {
92+
...current.client.connect,
93+
scopes: [...scopes],
94+
},
95+
};
96+
return await withPluginRuntimeGatewayRequestScope({ ...current, client }, run);
97+
}
98+
7099
const getInflightMap = (context: GatewayRequestContext) => {
71100
let inflight = inflightByContext.get(context);
72101
if (!inflight) {
@@ -525,26 +554,50 @@ export const sendHandlers: GatewayRequestHandlers = {
525554
});
526555
}
527556
const gatewayClientScopes = client?.connect?.scopes ?? [];
528-
const handled = await dispatchChannelMessageAction({
529-
channel,
530-
action: request.action as never,
531-
cfg,
532-
params: request.params,
533-
accountId,
534-
requesterAccountId: normalizeOptionalString(request.requesterAccountId) ?? undefined,
535-
requesterSenderId: normalizeOptionalString(request.requesterSenderId) ?? undefined,
536-
senderIsOwner: gatewayClientScopes.includes(ADMIN_SCOPE)
537-
? request.senderIsOwner === true
538-
: false,
539-
sessionKey,
540-
sessionId: normalizeOptionalString(request.sessionId) ?? undefined,
541-
inboundEventKind: request.inboundTurnKind,
542-
agentId,
543-
mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, agentId),
544-
toolContext: request.toolContext,
545-
dryRun: false,
546-
gatewayClientScopes,
547-
});
557+
// Requester provenance is trusted channel context, not public RPC input.
558+
// Only full-scope callers may bridge server-injected sender identity.
559+
const canSupplyTrustedRequester = gatewayClientScopes.includes(ADMIN_SCOPE);
560+
const requesterAccountId = canSupplyTrustedRequester
561+
? (normalizeOptionalString(request.requesterAccountId) ?? undefined)
562+
: undefined;
563+
const requesterSenderId = canSupplyTrustedRequester
564+
? (normalizeOptionalString(request.requesterSenderId) ?? undefined)
565+
: undefined;
566+
const senderIsOwner = canSupplyTrustedRequester ? request.senderIsOwner === true : false;
567+
const hasTrustedRequesterProvenance =
568+
requesterAccountId !== undefined ||
569+
requesterSenderId !== undefined ||
570+
(canSupplyTrustedRequester && request.senderIsOwner !== undefined);
571+
const isTrustedBackendBridge =
572+
canSupplyTrustedRequester &&
573+
client?.connect?.client?.id === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT &&
574+
client.connect.client.mode === GATEWAY_CLIENT_MODES.BACKEND &&
575+
hasTrustedRequesterProvenance;
576+
const dispatchGatewayClientScopes = isTrustedBackendBridge
577+
? TRUSTED_MESSAGE_ACTION_BRIDGE_SCOPES
578+
: gatewayClientScopes;
579+
const handled = await withMessageActionGatewayClientScopes(
580+
dispatchGatewayClientScopes,
581+
async () =>
582+
await dispatchChannelMessageAction({
583+
channel,
584+
action: request.action as never,
585+
cfg,
586+
params: request.params,
587+
accountId,
588+
requesterAccountId,
589+
requesterSenderId,
590+
senderIsOwner,
591+
sessionKey,
592+
sessionId: normalizeOptionalString(request.sessionId) ?? undefined,
593+
inboundEventKind: request.inboundTurnKind,
594+
agentId,
595+
mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, agentId),
596+
toolContext: request.toolContext,
597+
dryRun: false,
598+
gatewayClientScopes: dispatchGatewayClientScopes,
599+
}),
600+
);
548601
if (!handled) {
549602
const error = errorShape(
550603
ErrorCodes.INVALID_REQUEST,

0 commit comments

Comments
 (0)