From efe23c9994ff47eabb51a3587bcddd7106d268a2 Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:51:39 -0700 Subject: [PATCH] fix(imessage): require authorization for group actions (#97961) (cherry picked from commit 587eefe5ad916ca14a586eae7708031a21b7724e) --- extensions/imessage/src/actions.test.ts | 104 ++++++++++++++++++++++++ extensions/imessage/src/actions.ts | 30 ++++++- 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/extensions/imessage/src/actions.test.ts b/extensions/imessage/src/actions.test.ts index 401f6822c18e..57cae06fcc05 100644 --- a/extensions/imessage/src/actions.test.ts +++ b/extensions/imessage/src/actions.test.ts @@ -13,6 +13,11 @@ const runtimeMock = vi.hoisted(() => ({ sendReaction: vi.fn(), sendRichMessage: vi.fn(), sendAttachment: vi.fn(), + renameGroup: vi.fn(), + setGroupIcon: vi.fn(), + addParticipant: vi.fn(), + removeParticipant: vi.fn(), + leaveGroup: vi.fn(), })); const rememberIMessageReplyCacheMock = vi.hoisted(() => vi.fn()); @@ -90,6 +95,11 @@ describe("imessage message actions", () => { runtimeMock.sendReaction.mockReset(); runtimeMock.sendRichMessage.mockReset(); runtimeMock.sendAttachment.mockReset(); + runtimeMock.renameGroup.mockReset(); + runtimeMock.setGroupIcon.mockReset(); + runtimeMock.addParticipant.mockReset(); + runtimeMock.removeParticipant.mockReset(); + runtimeMock.leaveGroup.mockReset(); rememberIMessageReplyCacheMock.mockReset(); probeMock.getCachedIMessagePrivateApiStatus.mockReset(); probeMock.probeIMessagePrivateApi.mockReset(); @@ -183,6 +193,100 @@ describe("imessage message actions", () => { expect(described?.actions).toContain("edit"); }); + it("requires a trusted requester for group management from iMessage turns", () => { + for (const action of [ + "renameGroup", + "setGroupIcon", + "addParticipant", + "removeParticipant", + "leaveGroup", + ] as const) { + expect( + imessageMessageActions.requiresTrustedRequesterSender?.({ + action, + toolContext: { currentChannelProvider: "imessage" }, + }), + ).toBe(true); + } + expect( + imessageMessageActions.requiresTrustedRequesterSender?.({ + action: "renameGroup", + toolContext: { currentChannelProvider: "discord" }, + }), + ).toBe(false); + expect( + imessageMessageActions.requiresTrustedRequesterSender?.({ + action: "react", + toolContext: { currentChannelProvider: "imessage" }, + }), + ).toBe(false); + }); + + it.each([ + ["renameGroup", { name: "Unauthorized rename" }, runtimeMock.renameGroup], + [ + "setGroupIcon", + { buffer: Buffer.from("unauthorized icon").toString("base64"), filename: "icon.png" }, + runtimeMock.setGroupIcon, + ], + ["addParticipant", { address: "+15551230001" }, runtimeMock.addParticipant], + ["removeParticipant", { address: "+15551230002" }, runtimeMock.removeParticipant], + ["leaveGroup", {}, runtimeMock.leaveGroup], + ] as const)( + "rejects %s from non-owner non-admin callers before native mutation", + async (action, params, runtimeAction) => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + await expect( + imessageMessageActions.handleAction?.({ + action, + cfg: cfg(), + params: { chatGuid: "iMessage;+;chat0000", ...params }, + senderIsOwner: false, + gatewayClientScopes: ["operator.write"], + } as never), + ).rejects.toThrow("iMessage group management requires an owner or operator.admin requester."); + expect(runtimeAction).not.toHaveBeenCalled(); + }, + ); + + it("allows owner and operator.admin group management", async () => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.renameGroup.mockResolvedValue(undefined); + runtimeMock.leaveGroup.mockResolvedValue(undefined); + + await imessageMessageActions.handleAction?.({ + action: "renameGroup", + cfg: cfg(), + params: { chatGuid: "iMessage;+;chat0000", name: "Renamed group" }, + senderIsOwner: true, + } as never); + await imessageMessageActions.handleAction?.({ + action: "leaveGroup", + cfg: cfg(), + params: { chatGuid: "iMessage;+;chat0000" }, + senderIsOwner: false, + gatewayClientScopes: ["operator.admin"], + } as never); + + expect(runtimeMock.renameGroup).toHaveBeenCalledWith({ + chatGuid: "iMessage;+;chat0000", + displayName: "Renamed group", + options: imsgOptions("iMessage;+;chat0000"), + }); + expect(runtimeMock.leaveGroup).toHaveBeenCalledWith({ + chatGuid: "iMessage;+;chat0000", + options: imsgOptions("iMessage;+;chat0000"), + }); + }); + it("emits a channels/imessage WARN when the private API bridge is unavailable", async () => { probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue(undefined); probeMock.probeIMessagePrivateApi.mockResolvedValue({ diff --git a/extensions/imessage/src/actions.ts b/extensions/imessage/src/actions.ts index 17d71524189d..bb899f77d6d1 100644 --- a/extensions/imessage/src/actions.ts +++ b/extensions/imessage/src/actions.ts @@ -41,6 +41,14 @@ const SUPPORTED_ACTIONS = new Set([ ...IMESSAGE_ACTION_NAMES, "upload-file", ]); +const GROUP_MANAGEMENT_ACTIONS = new Set([ + "renameGroup", + "setGroupIcon", + "addParticipant", + "removeParticipant", + "leaveGroup", +]); + function readMessageText(params: Record): string | undefined { return readStringParam(params, "text") ?? readStringParam(params, "message"); } @@ -388,6 +396,9 @@ function assertActionEnabled( export const imessageMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeIMessageMessageTool, supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), + requiresTrustedRequesterSender: ({ action, toolContext }) => + normalizeOptionalLowercaseString(toolContext?.currentChannelProvider) === "imessage" && + GROUP_MANAGEMENT_ACTIONS.has(action), messageActionTargetAliases: { react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, edit: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] }, @@ -415,7 +426,24 @@ export const imessageMessageActions: ChannelMessageActionAdapter = { leaveGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, }, extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), - handleAction: async ({ action, params, cfg, accountId, toolContext }) => { + handleAction: async ({ + action, + params, + cfg, + accountId, + toolContext, + senderIsOwner, + gatewayClientScopes, + }) => { + // Group administration mutates the host's Messages identity, so model-driven + // actions need owner provenance or an admin-scoped Gateway caller. + if ( + GROUP_MANAGEMENT_ACTIONS.has(action) && + senderIsOwner !== true && + !gatewayClientScopes?.includes("operator.admin") + ) { + throw new Error("iMessage group management requires an owner or operator.admin requester."); + } const runtime = await loadIMessageActionsRuntime(); const account = resolveIMessageAccount({ cfg,