Skip to content

Commit 008cf0e

Browse files
committed
fix(imessage): preserve service-prefixed direct handles as approvers
ClawSweeper P1 review finding on #85952. normalizeIMessageApproverId was calling looksLikeIMessageExplicitTargetId() to reject conversation-target prefixes, but that helper also matches the imessage:/sms:/auto: service prefixes — which are valid direct-handle forms. Any allowFrom entry like 'imessage:+15551230000' dropped to undefined, leaving approvers empty, which: - silently denied reaction resolution ('reactions require explicit approvers'), and - let text /approve fall back to implicit same-chat authorization. Fix: normalize first via normalizeIMessageHandle (strips the service prefix), then reject only chat_id:/chat_guid:/chat_identifier: conversation-target shapes that remain after normalization. Tests: - approval-auth.test.ts: assert the resolved approver list contains the normalized handle, plus the corollary that a non-matching sender is explicitly rejected (no longer masked by the implicit-same-chat fallback). Add a separate case covering chat_id/chat_guid/ chat_identifier rejection (with and without a service prefix). - approval-reactions.test.ts: reaction resolution end-to-end with a service-prefixed allowFrom entry — proves resolveIMessageApproval is called rather than silently denied. Focused suite: 48 passed (was 47).
1 parent 63352d3 commit 008cf0e

3 files changed

Lines changed: 103 additions & 5 deletions

File tree

extensions/imessage/src/approval-auth.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,31 @@ describe("imessageApprovalAuth", () => {
7676
).toEqual({ authorized: true });
7777
});
7878

79-
it("strips imessage:/sms:/auto: service prefixes when matching senders", () => {
79+
it("strips imessage:/sms:/auto: service prefixes when normalizing approver entries", () => {
80+
// The resolved approver list itself must contain the bare normalized
81+
// handle — a previous bug rejected service-prefixed entries entirely,
82+
// which silently fell back to the empty-approvers implicit-same-chat
83+
// authorization and masked the regression. Assert the explicit list here
84+
// so reaction resolution (which requires a non-empty approver list) works
85+
// for service-prefixed allowFrom values too.
86+
expect(
87+
getIMessageApprovalApprovers({
88+
cfg: { channels: { imessage: { allowFrom: ["imessage:+15551230000"] } } },
89+
}),
90+
).toEqual(["+15551230000"]);
91+
expect(
92+
getIMessageApprovalApprovers({
93+
cfg: { channels: { imessage: { allowFrom: ["sms:+15551230001"] } } },
94+
}),
95+
).toEqual(["+15551230001"]);
96+
expect(
97+
getIMessageApprovalApprovers({
98+
cfg: { channels: { imessage: { allowFrom: ["auto:[email protected]"] } } },
99+
}),
100+
).toEqual(["[email protected]"]);
101+
102+
// A sender that matches the normalized handle is explicitly authorized
103+
// (not via the implicit same-chat fallback).
80104
expect(
81105
imessageApprovalAuth.authorizeActorAction({
82106
cfg: { channels: { imessage: { allowFrom: ["imessage:+15551230000"] } } },
@@ -85,5 +109,38 @@ describe("imessageApprovalAuth", () => {
85109
approvalKind: "exec",
86110
}),
87111
).toEqual({ authorized: true });
112+
113+
// And a NON-matching sender is rejected — proving the entry was added
114+
// to the approver list rather than collapsing to empty.
115+
expect(
116+
imessageApprovalAuth.authorizeActorAction({
117+
cfg: { channels: { imessage: { allowFrom: ["imessage:+15551230000"] } } },
118+
senderId: "+15559999999",
119+
action: "approve",
120+
approvalKind: "exec",
121+
}),
122+
).toEqual({
123+
authorized: false,
124+
reason: "❌ You are not authorized to approve exec requests on iMessage.",
125+
});
126+
});
127+
128+
it("rejects chat_id / chat_guid / chat_identifier as approver entries even with service prefixes", () => {
129+
expect(
130+
getIMessageApprovalApprovers({
131+
cfg: {
132+
channels: {
133+
imessage: {
134+
allowFrom: [
135+
"chat_id:42",
136+
"chat_guid:iMessage;+;chat42",
137+
"chat_identifier:[email protected]",
138+
"imessage:chat_id:43",
139+
],
140+
},
141+
},
142+
},
143+
}),
144+
).toEqual([]);
88145
});
89146
});

extensions/imessage/src/approval-auth.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
resolveApprovalApprovers,
44
} from "openclaw/plugin-sdk/approval-auth-runtime";
55
import { resolveIMessageAccount } from "./accounts.js";
6-
import { looksLikeIMessageExplicitTargetId, normalizeIMessageHandle } from "./targets.js";
6+
import { normalizeIMessageHandle } from "./targets.js";
77

88
type ApprovalKind = "exec" | "plugin";
99

@@ -12,9 +12,12 @@ export function normalizeIMessageApproverId(value: string | number): string | un
1212
if (!raw) {
1313
return undefined;
1414
}
15-
if (looksLikeIMessageExplicitTargetId(raw)) {
16-
return undefined;
17-
}
15+
// Normalize first so service-prefixed direct handles (`imessage:+...`,
16+
// `sms:+...`, `auto:+...`) are stripped to their bare identifier before we
17+
// decide whether to reject the entry. After normalization only the
18+
// conversation-target prefixes (chat_id / chat_guid / chat_identifier) remain
19+
// as illegal approver shapes — service-prefixed direct handles are valid
20+
// approver values that map to a specific phone/email.
1821
const normalized = normalizeIMessageHandle(raw);
1922
if (
2023
!normalized ||

extensions/imessage/src/approval-reactions.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,44 @@ describe("iMessage approval reactions", () => {
284284
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledTimes(1);
285285
});
286286

287+
it("resolves a reaction when the approver was configured with a service-prefixed allowFrom entry", async () => {
288+
// Regression test for the ClawSweeper-flagged normalizer bug: a previous
289+
// version of normalizeIMessageApproverId rejected service-prefixed direct
290+
// handles (`imessage:+...`, `sms:+...`, `auto:+...`) before stripping the
291+
// prefix, so the approver list collapsed to empty and reaction resolution
292+
// silently denied with "reactions require explicit approvers".
293+
registerIMessageApprovalReactionTarget({
294+
accountId: "default",
295+
conversation: { handle: "+15551230000" },
296+
messageId: "approval-message",
297+
approvalId: "exec-service-prefix",
298+
allowedDecisions: ["allow-once", "deny"],
299+
});
300+
301+
const cfg = {
302+
channels: { imessage: { allowFrom: ["imessage:+15551230000"] } },
303+
};
304+
const handled = await maybeResolveIMessageApprovalReaction({
305+
cfg,
306+
accountId: "default",
307+
message: buildTapbackReactionPayload({
308+
sender: "+15551230000",
309+
reaction_emoji: "👍",
310+
reacted_to_guid: "approval-message",
311+
}),
312+
bodyText: "",
313+
});
314+
315+
expect(handled).toBe(true);
316+
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({
317+
cfg,
318+
approvalId: "exec-service-prefix",
319+
decision: "allow-once",
320+
senderId: "+15551230000",
321+
gatewayUrl: undefined,
322+
});
323+
});
324+
287325
it("resolves DM reactions even when send registered under handle but inbound carries chat_guid", async () => {
288326
registerIMessageApprovalReactionTarget({
289327
accountId: "default",

0 commit comments

Comments
 (0)