fix(imessage): require authorization for group actions (#97961)

(cherry picked from commit 587eefe5ad)
This commit is contained in:
Agustin Rivera
2026-06-29 17:51:39 -07:00
committed by Dallin Romney
parent 274a54f7e2
commit 391b5d3204
2 changed files with 133 additions and 1 deletions
+104
View File
@@ -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({
+29 -1
View File
@@ -41,6 +41,14 @@ const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
...IMESSAGE_ACTION_NAMES,
"upload-file",
]);
const GROUP_MANAGEMENT_ACTIONS = new Set<ChannelMessageActionName>([
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
]);
function readMessageText(params: Record<string, unknown>): 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,