Skip to content

Commit ec9c21e

Browse files
eleqtrizitcron
authored andcommitted
fix(imessage): require authorization for group actions (openclaw#97961)
(cherry picked from commit 587eefe)
1 parent a00d1cc commit ec9c21e

2 files changed

Lines changed: 133 additions & 1 deletion

File tree

extensions/imessage/src/actions.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ const runtimeMock = vi.hoisted(() => ({
1313
sendReaction: vi.fn(),
1414
sendRichMessage: vi.fn(),
1515
sendAttachment: vi.fn(),
16+
renameGroup: vi.fn(),
17+
setGroupIcon: vi.fn(),
18+
addParticipant: vi.fn(),
19+
removeParticipant: vi.fn(),
20+
leaveGroup: vi.fn(),
1621
}));
1722

1823
const rememberIMessageReplyCacheMock = vi.hoisted(() => vi.fn());
@@ -90,6 +95,11 @@ describe("imessage message actions", () => {
9095
runtimeMock.sendReaction.mockReset();
9196
runtimeMock.sendRichMessage.mockReset();
9297
runtimeMock.sendAttachment.mockReset();
98+
runtimeMock.renameGroup.mockReset();
99+
runtimeMock.setGroupIcon.mockReset();
100+
runtimeMock.addParticipant.mockReset();
101+
runtimeMock.removeParticipant.mockReset();
102+
runtimeMock.leaveGroup.mockReset();
93103
rememberIMessageReplyCacheMock.mockReset();
94104
probeMock.getCachedIMessagePrivateApiStatus.mockReset();
95105
probeMock.probeIMessagePrivateApi.mockReset();
@@ -183,6 +193,100 @@ describe("imessage message actions", () => {
183193
expect(described?.actions).toContain("edit");
184194
});
185195

196+
it("requires a trusted requester for group management from iMessage turns", () => {
197+
for (const action of [
198+
"renameGroup",
199+
"setGroupIcon",
200+
"addParticipant",
201+
"removeParticipant",
202+
"leaveGroup",
203+
] as const) {
204+
expect(
205+
imessageMessageActions.requiresTrustedRequesterSender?.({
206+
action,
207+
toolContext: { currentChannelProvider: "imessage" },
208+
}),
209+
).toBe(true);
210+
}
211+
expect(
212+
imessageMessageActions.requiresTrustedRequesterSender?.({
213+
action: "renameGroup",
214+
toolContext: { currentChannelProvider: "discord" },
215+
}),
216+
).toBe(false);
217+
expect(
218+
imessageMessageActions.requiresTrustedRequesterSender?.({
219+
action: "react",
220+
toolContext: { currentChannelProvider: "imessage" },
221+
}),
222+
).toBe(false);
223+
});
224+
225+
it.each([
226+
["renameGroup", { name: "Unauthorized rename" }, runtimeMock.renameGroup],
227+
[
228+
"setGroupIcon",
229+
{ buffer: Buffer.from("unauthorized icon").toString("base64"), filename: "icon.png" },
230+
runtimeMock.setGroupIcon,
231+
],
232+
["addParticipant", { address: "+15551230001" }, runtimeMock.addParticipant],
233+
["removeParticipant", { address: "+15551230002" }, runtimeMock.removeParticipant],
234+
["leaveGroup", {}, runtimeMock.leaveGroup],
235+
] as const)(
236+
"rejects %s from non-owner non-admin callers before native mutation",
237+
async (action, params, runtimeAction) => {
238+
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
239+
available: true,
240+
v2Ready: true,
241+
selectors: {},
242+
});
243+
await expect(
244+
imessageMessageActions.handleAction?.({
245+
action,
246+
cfg: cfg(),
247+
params: { chatGuid: "iMessage;+;chat0000", ...params },
248+
senderIsOwner: false,
249+
gatewayClientScopes: ["operator.write"],
250+
} as never),
251+
).rejects.toThrow("iMessage group management requires an owner or operator.admin requester.");
252+
expect(runtimeAction).not.toHaveBeenCalled();
253+
},
254+
);
255+
256+
it("allows owner and operator.admin group management", async () => {
257+
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
258+
available: true,
259+
v2Ready: true,
260+
selectors: {},
261+
});
262+
runtimeMock.renameGroup.mockResolvedValue(undefined);
263+
runtimeMock.leaveGroup.mockResolvedValue(undefined);
264+
265+
await imessageMessageActions.handleAction?.({
266+
action: "renameGroup",
267+
cfg: cfg(),
268+
params: { chatGuid: "iMessage;+;chat0000", name: "Renamed group" },
269+
senderIsOwner: true,
270+
} as never);
271+
await imessageMessageActions.handleAction?.({
272+
action: "leaveGroup",
273+
cfg: cfg(),
274+
params: { chatGuid: "iMessage;+;chat0000" },
275+
senderIsOwner: false,
276+
gatewayClientScopes: ["operator.admin"],
277+
} as never);
278+
279+
expect(runtimeMock.renameGroup).toHaveBeenCalledWith({
280+
chatGuid: "iMessage;+;chat0000",
281+
displayName: "Renamed group",
282+
options: imsgOptions("iMessage;+;chat0000"),
283+
});
284+
expect(runtimeMock.leaveGroup).toHaveBeenCalledWith({
285+
chatGuid: "iMessage;+;chat0000",
286+
options: imsgOptions("iMessage;+;chat0000"),
287+
});
288+
});
289+
186290
it("emits a channels/imessage WARN when the private API bridge is unavailable", async () => {
187291
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue(undefined);
188292
probeMock.probeIMessagePrivateApi.mockResolvedValue({

extensions/imessage/src/actions.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
4141
...IMESSAGE_ACTION_NAMES,
4242
"upload-file",
4343
]);
44+
const GROUP_MANAGEMENT_ACTIONS = new Set<ChannelMessageActionName>([
45+
"renameGroup",
46+
"setGroupIcon",
47+
"addParticipant",
48+
"removeParticipant",
49+
"leaveGroup",
50+
]);
51+
4452
function readMessageText(params: Record<string, unknown>): string | undefined {
4553
return readStringParam(params, "text") ?? readStringParam(params, "message");
4654
}
@@ -388,6 +396,9 @@ function assertActionEnabled(
388396
export const imessageMessageActions: ChannelMessageActionAdapter = {
389397
describeMessageTool: describeIMessageMessageTool,
390398
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
399+
requiresTrustedRequesterSender: ({ action, toolContext }) =>
400+
normalizeOptionalLowercaseString(toolContext?.currentChannelProvider) === "imessage" &&
401+
GROUP_MANAGEMENT_ACTIONS.has(action),
391402
messageActionTargetAliases: {
392403
react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
393404
edit: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
@@ -415,7 +426,24 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
415426
leaveGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
416427
},
417428
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
418-
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
429+
handleAction: async ({
430+
action,
431+
params,
432+
cfg,
433+
accountId,
434+
toolContext,
435+
senderIsOwner,
436+
gatewayClientScopes,
437+
}) => {
438+
// Group administration mutates the host's Messages identity, so model-driven
439+
// actions need owner provenance or an admin-scoped Gateway caller.
440+
if (
441+
GROUP_MANAGEMENT_ACTIONS.has(action) &&
442+
senderIsOwner !== true &&
443+
!gatewayClientScopes?.includes("operator.admin")
444+
) {
445+
throw new Error("iMessage group management requires an owner or operator.admin requester.");
446+
}
419447
const runtime = await loadIMessageActionsRuntime();
420448
const account = resolveIMessageAccount({
421449
cfg,

0 commit comments

Comments
 (0)