mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(imessage): require authorization for group actions (#97961)
(cherry picked from commit 587eefe5ad)
This commit is contained in:
committed by
Dallin Romney
parent
274a54f7e2
commit
391b5d3204
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user