Skip to content

Commit c5415a4

Browse files
fix(msteams): align feedback invoke authorization (#55108)
* msteams: align feedback invoke authorization * msteams: fix feedback allowlist regressions * msteams: tighten feedback group authorization
1 parent 269282a commit c5415a4

5 files changed

Lines changed: 617 additions & 90 deletions

File tree

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import path from "node:path";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
6+
import type { MSTeamsConversationStore } from "./conversation-store.js";
7+
import type { MSTeamsAdapter } from "./messenger.js";
8+
import {
9+
type MSTeamsActivityHandler,
10+
type MSTeamsMessageHandlerDeps,
11+
registerMSTeamsHandlers,
12+
} from "./monitor-handler.js";
13+
import type { MSTeamsPollStore } from "./polls.js";
14+
import { setMSTeamsRuntime } from "./runtime.js";
15+
import type { MSTeamsTurnContext } from "./sdk-types.js";
16+
17+
const feedbackReflectionMockState = vi.hoisted(() => ({
18+
runFeedbackReflection: vi.fn(),
19+
}));
20+
21+
vi.mock("./feedback-reflection.js", async () => {
22+
const actual = await vi.importActual<typeof import("./feedback-reflection.js")>(
23+
"./feedback-reflection.js",
24+
);
25+
return {
26+
...actual,
27+
runFeedbackReflection: feedbackReflectionMockState.runFeedbackReflection,
28+
};
29+
});
30+
31+
function createRuntimeStub(readAllowFromStore: ReturnType<typeof vi.fn>): PluginRuntime {
32+
return {
33+
logging: {
34+
shouldLogVerbose: () => false,
35+
},
36+
channel: {
37+
debounce: {
38+
resolveInboundDebounceMs: () => 0,
39+
createInboundDebouncer: () => ({
40+
enqueue: async () => {},
41+
}),
42+
},
43+
pairing: {
44+
readAllowFromStore,
45+
upsertPairingRequest: vi.fn(async () => null),
46+
},
47+
routing: {
48+
resolveAgentRoute: ({ peer }: { peer: { kind: string; id: string } }) => ({
49+
sessionKey: `msteams:${peer.kind}:${peer.id}`,
50+
agentId: "default",
51+
}),
52+
},
53+
session: {
54+
resolveStorePath: (storePath?: string) => storePath ?? tmpdir(),
55+
},
56+
},
57+
} as unknown as PluginRuntime;
58+
}
59+
60+
function createActivityHandler(run = vi.fn(async () => undefined)): MSTeamsActivityHandler & {
61+
run: NonNullable<MSTeamsActivityHandler["run"]>;
62+
} {
63+
let handler: MSTeamsActivityHandler & {
64+
run: NonNullable<MSTeamsActivityHandler["run"]>;
65+
};
66+
handler = {
67+
onMessage: () => handler,
68+
onMembersAdded: () => handler,
69+
onReactionsAdded: () => handler,
70+
onReactionsRemoved: () => handler,
71+
run,
72+
};
73+
return handler;
74+
}
75+
76+
function createDeps(params: {
77+
cfg: OpenClawConfig;
78+
readAllowFromStore?: ReturnType<typeof vi.fn>;
79+
}): MSTeamsMessageHandlerDeps {
80+
const readAllowFromStore = params.readAllowFromStore ?? vi.fn(async () => []);
81+
setMSTeamsRuntime(createRuntimeStub(readAllowFromStore));
82+
83+
const adapter: MSTeamsAdapter = {
84+
continueConversation: async () => {},
85+
process: async () => {},
86+
updateActivity: async () => {},
87+
deleteActivity: async () => {},
88+
};
89+
const conversationStore: MSTeamsConversationStore = {
90+
upsert: async () => {},
91+
get: async () => null,
92+
list: async () => [],
93+
remove: async () => false,
94+
findByUserId: async () => null,
95+
};
96+
const pollStore: MSTeamsPollStore = {
97+
createPoll: async () => {},
98+
getPoll: async () => null,
99+
recordVote: async () => null,
100+
};
101+
102+
return {
103+
cfg: params.cfg,
104+
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
105+
appId: "test-app-id",
106+
adapter,
107+
tokenProvider: {
108+
getAccessToken: async () => "token",
109+
},
110+
textLimit: 4000,
111+
mediaMaxBytes: 8 * 1024 * 1024,
112+
conversationStore,
113+
pollStore,
114+
log: {
115+
info: vi.fn(),
116+
error: vi.fn(),
117+
debug: vi.fn(),
118+
},
119+
};
120+
}
121+
122+
function createFeedbackInvokeContext(params: {
123+
reaction: "like" | "dislike";
124+
conversationId: string;
125+
conversationType: string;
126+
senderId: string;
127+
senderName?: string;
128+
teamId?: string;
129+
channelName?: string;
130+
comment?: string;
131+
}): MSTeamsTurnContext {
132+
return {
133+
activity: {
134+
id: `invoke-${params.reaction}`,
135+
type: "invoke",
136+
name: "message/submitAction",
137+
channelId: "msteams",
138+
serviceUrl: "https://service.example.test",
139+
from: {
140+
id: `${params.senderId}-botframework`,
141+
aadObjectId: params.senderId,
142+
name: params.senderName ?? "Sender",
143+
},
144+
recipient: {
145+
id: "bot-id",
146+
name: "Bot",
147+
},
148+
conversation: {
149+
id: params.conversationId,
150+
conversationType: params.conversationType,
151+
tenantId: params.teamId ? "tenant-1" : undefined,
152+
},
153+
channelData: params.teamId
154+
? {
155+
team: { id: params.teamId, name: "Team 1" },
156+
channel: params.channelName ? { name: params.channelName } : undefined,
157+
}
158+
: {},
159+
value: {
160+
actionName: "feedback",
161+
actionValue: {
162+
reaction: params.reaction,
163+
feedback: JSON.stringify({ feedbackText: params.comment ?? "feedback text" }),
164+
},
165+
replyToId: "bot-msg-1",
166+
},
167+
},
168+
sendActivity: vi.fn(async () => ({ id: "ignored" })),
169+
sendActivities: async () => [],
170+
} as unknown as MSTeamsTurnContext;
171+
}
172+
173+
async function expectFileMissing(filePath: string) {
174+
await expect(access(filePath)).rejects.toThrow();
175+
}
176+
177+
describe("msteams feedback invoke authz", () => {
178+
beforeEach(() => {
179+
feedbackReflectionMockState.runFeedbackReflection.mockReset();
180+
feedbackReflectionMockState.runFeedbackReflection.mockResolvedValue(undefined);
181+
});
182+
183+
it("records feedback for an allowlisted DM sender", async () => {
184+
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
185+
try {
186+
const originalRun = vi.fn(async () => undefined);
187+
const handler = registerMSTeamsHandlers(
188+
createActivityHandler(originalRun),
189+
createDeps({
190+
cfg: {
191+
session: { store: tmpDir },
192+
channels: {
193+
msteams: {
194+
dmPolicy: "allowlist",
195+
allowFrom: ["owner-aad"],
196+
},
197+
},
198+
} as OpenClawConfig,
199+
}),
200+
) as MSTeamsActivityHandler & {
201+
run: NonNullable<MSTeamsActivityHandler["run"]>;
202+
};
203+
204+
await handler.run(
205+
createFeedbackInvokeContext({
206+
reaction: "like",
207+
conversationId: "a:personal-chat;messageid=bot-msg-1",
208+
conversationType: "personal",
209+
senderId: "owner-aad",
210+
senderName: "Owner",
211+
comment: "allowed feedback",
212+
}),
213+
);
214+
215+
const transcript = await readFile(
216+
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
217+
"utf-8",
218+
);
219+
expect(JSON.parse(transcript.trim())).toMatchObject({
220+
event: "feedback",
221+
messageId: "bot-msg-1",
222+
value: "positive",
223+
comment: "allowed feedback",
224+
sessionKey: "msteams:direct:owner-aad",
225+
conversationId: "a:personal-chat",
226+
});
227+
expect(originalRun).not.toHaveBeenCalled();
228+
} finally {
229+
await rm(tmpDir, { recursive: true, force: true });
230+
}
231+
});
232+
233+
it("keeps DM feedback allowed when team route allowlists exist", async () => {
234+
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
235+
try {
236+
const originalRun = vi.fn(async () => undefined);
237+
const handler = registerMSTeamsHandlers(
238+
createActivityHandler(originalRun),
239+
createDeps({
240+
cfg: {
241+
session: { store: tmpDir },
242+
channels: {
243+
msteams: {
244+
dmPolicy: "allowlist",
245+
allowFrom: ["owner-aad"],
246+
teams: {
247+
team123: {
248+
channels: {
249+
"19:group@thread.tacv2": { requireMention: false },
250+
},
251+
},
252+
},
253+
},
254+
},
255+
} as OpenClawConfig,
256+
}),
257+
) as MSTeamsActivityHandler & {
258+
run: NonNullable<MSTeamsActivityHandler["run"]>;
259+
};
260+
261+
await handler.run(
262+
createFeedbackInvokeContext({
263+
reaction: "like",
264+
conversationId: "a:personal-chat;messageid=bot-msg-1",
265+
conversationType: "personal",
266+
senderId: "owner-aad",
267+
senderName: "Owner",
268+
comment: "allowed dm feedback",
269+
}),
270+
);
271+
272+
const transcript = await readFile(
273+
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
274+
"utf-8",
275+
);
276+
expect(JSON.parse(transcript.trim())).toMatchObject({
277+
event: "feedback",
278+
value: "positive",
279+
comment: "allowed dm feedback",
280+
sessionKey: "msteams:direct:owner-aad",
281+
});
282+
expect(originalRun).not.toHaveBeenCalled();
283+
} finally {
284+
await rm(tmpDir, { recursive: true, force: true });
285+
}
286+
});
287+
288+
it("does not record feedback for a DM sender outside allowFrom", async () => {
289+
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
290+
try {
291+
const originalRun = vi.fn(async () => undefined);
292+
const handler = registerMSTeamsHandlers(
293+
createActivityHandler(originalRun),
294+
createDeps({
295+
cfg: {
296+
session: { store: tmpDir },
297+
channels: {
298+
msteams: {
299+
dmPolicy: "allowlist",
300+
allowFrom: ["owner-aad"],
301+
},
302+
},
303+
} as OpenClawConfig,
304+
}),
305+
) as MSTeamsActivityHandler & {
306+
run: NonNullable<MSTeamsActivityHandler["run"]>;
307+
};
308+
309+
await handler.run(
310+
createFeedbackInvokeContext({
311+
reaction: "like",
312+
conversationId: "a:personal-chat;messageid=bot-msg-1",
313+
conversationType: "personal",
314+
senderId: "attacker-aad",
315+
senderName: "Attacker",
316+
comment: "blocked feedback",
317+
}),
318+
);
319+
320+
await expectFileMissing(path.join(tmpDir, "msteams_direct_attacker-aad.jsonl"));
321+
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
322+
expect(originalRun).not.toHaveBeenCalled();
323+
} finally {
324+
await rm(tmpDir, { recursive: true, force: true });
325+
}
326+
});
327+
328+
it("does not trigger reflection for a group sender outside groupAllowFrom", async () => {
329+
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
330+
try {
331+
const originalRun = vi.fn(async () => undefined);
332+
const handler = registerMSTeamsHandlers(
333+
createActivityHandler(originalRun),
334+
createDeps({
335+
cfg: {
336+
session: { store: tmpDir },
337+
channels: {
338+
msteams: {
339+
groupPolicy: "allowlist",
340+
groupAllowFrom: ["owner-aad"],
341+
feedbackReflection: true,
342+
},
343+
},
344+
} as OpenClawConfig,
345+
}),
346+
) as MSTeamsActivityHandler & {
347+
run: NonNullable<MSTeamsActivityHandler["run"]>;
348+
};
349+
350+
await handler.run(
351+
createFeedbackInvokeContext({
352+
reaction: "dislike",
353+
conversationId: "19:group@thread.tacv2;messageid=bot-msg-1",
354+
conversationType: "groupChat",
355+
senderId: "attacker-aad",
356+
senderName: "Attacker",
357+
teamId: "team-1",
358+
channelName: "General",
359+
comment: "blocked reflection",
360+
}),
361+
);
362+
363+
await expectFileMissing(path.join(tmpDir, "msteams_group_19_group_thread_tacv2.jsonl"));
364+
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
365+
expect(originalRun).not.toHaveBeenCalled();
366+
} finally {
367+
await rm(tmpDir, { recursive: true, force: true });
368+
}
369+
});
370+
});

0 commit comments

Comments
 (0)