@openclaw/msteams 2026.3.11 → 2026.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.13
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.12
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.3.11
4
16
 
5
17
  ### Changes
18
+
6
19
  - Version alignment with core OpenClaw release numbers.
7
20
 
8
21
  ## 2026.3.10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/msteams",
3
- "version": "2026.3.11",
3
+ "version": "2026.3.13",
4
4
  "description": "OpenClaw Microsoft Teams channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -31,6 +31,23 @@ function mockFetchWithRedirect(redirectMap: Record<string, string>, finalBody =
31
31
  });
32
32
  }
33
33
 
34
+ async function expectSafeFetchStatus(params: {
35
+ fetchMock: ReturnType<typeof vi.fn>;
36
+ url: string;
37
+ allowHosts: string[];
38
+ expectedStatus: number;
39
+ resolveFn?: typeof publicResolve;
40
+ }) {
41
+ const res = await safeFetch({
42
+ url: params.url,
43
+ allowHosts: params.allowHosts,
44
+ fetchFn: params.fetchMock as unknown as typeof fetch,
45
+ resolveFn: params.resolveFn ?? publicResolve,
46
+ });
47
+ expect(res.status).toBe(params.expectedStatus);
48
+ return res;
49
+ }
50
+
34
51
  describe("msteams attachment allowlists", () => {
35
52
  it("normalizes wildcard host lists", () => {
36
53
  expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
@@ -121,13 +138,12 @@ describe("safeFetch", () => {
121
138
  const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
122
139
  return new Response("ok", { status: 200 });
123
140
  });
124
- const res = await safeFetch({
141
+ await expectSafeFetchStatus({
142
+ fetchMock,
125
143
  url: "https://teams.sharepoint.com/file.pdf",
126
144
  allowHosts: ["sharepoint.com"],
127
- fetchFn: fetchMock as unknown as typeof fetch,
128
- resolveFn: publicResolve,
145
+ expectedStatus: 200,
129
146
  });
130
- expect(res.status).toBe(200);
131
147
  expect(fetchMock).toHaveBeenCalledOnce();
132
148
  // Should have used redirect: "manual"
133
149
  expect(fetchMock.mock.calls[0][1]).toHaveProperty("redirect", "manual");
@@ -137,13 +153,12 @@ describe("safeFetch", () => {
137
153
  const fetchMock = mockFetchWithRedirect({
138
154
  "https://teams.sharepoint.com/file.pdf": "https://cdn.sharepoint.com/storage/file.pdf",
139
155
  });
140
- const res = await safeFetch({
156
+ await expectSafeFetchStatus({
157
+ fetchMock,
141
158
  url: "https://teams.sharepoint.com/file.pdf",
142
159
  allowHosts: ["sharepoint.com"],
143
- fetchFn: fetchMock as unknown as typeof fetch,
144
- resolveFn: publicResolve,
160
+ expectedStatus: 200,
145
161
  });
146
- expect(res.status).toBe(200);
147
162
  expect(fetchMock).toHaveBeenCalledTimes(2);
148
163
  });
149
164
 
@@ -88,14 +88,17 @@ function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean {
88
88
  );
89
89
  }
90
90
 
91
- const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
91
+ async function fetchRemoteMediaWithRedirects(
92
+ params: RemoteMediaFetchParams,
93
+ requestInit?: RequestInit,
94
+ ) {
92
95
  const fetchFn = params.fetchImpl ?? fetch;
93
96
  let currentUrl = params.url;
94
97
  for (let i = 0; i <= MAX_REDIRECT_HOPS; i += 1) {
95
98
  if (!isUrlAllowedBySsrfPolicy(currentUrl, params.ssrfPolicy)) {
96
99
  throw new Error(`Blocked hostname (not in allowlist): ${currentUrl}`);
97
100
  }
98
- const res = await fetchFn(currentUrl, { redirect: "manual" });
101
+ const res = await fetchFn(currentUrl, { redirect: "manual", ...requestInit });
99
102
  if (REDIRECT_STATUS_CODES.includes(res.status)) {
100
103
  const location = res.headers.get("location");
101
104
  if (!location) {
@@ -107,6 +110,10 @@ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
107
110
  return readRemoteMediaResponse(res, params);
108
111
  }
109
112
  throw new Error("too many redirects");
113
+ }
114
+
115
+ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
116
+ return await fetchRemoteMediaWithRedirects(params);
110
117
  });
111
118
 
112
119
  const runtimeStub: PluginRuntime = createPluginRuntimeMock({
@@ -720,24 +727,9 @@ describe("msteams attachments", () => {
720
727
  });
721
728
 
722
729
  fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
723
- const fetchFn = params.fetchImpl ?? fetch;
724
- let currentUrl = params.url;
725
- for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
726
- const res = await fetchFn(currentUrl, {
727
- redirect: "manual",
728
- dispatcher: {},
729
- } as RequestInit);
730
- if (REDIRECT_STATUS_CODES.includes(res.status)) {
731
- const location = res.headers.get("location");
732
- if (!location) {
733
- throw new Error("redirect missing location");
734
- }
735
- currentUrl = new URL(location, currentUrl).toString();
736
- continue;
737
- }
738
- return readRemoteMediaResponse(res, params);
739
- }
740
- throw new Error("too many redirects");
730
+ return await fetchRemoteMediaWithRedirects(params, {
731
+ dispatcher: {},
732
+ } as RequestInit);
741
733
  });
742
734
 
743
735
  const media = await downloadAttachmentsWithFetch(
@@ -1,15 +1,10 @@
1
1
  import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/msteams";
2
2
  import { describe, expect, it } from "vitest";
3
+ import { createDirectoryTestRuntime, expectDirectorySurface } from "../../test-utils/directory.js";
3
4
  import { msteamsPlugin } from "./channel.js";
4
5
 
5
6
  describe("msteams directory", () => {
6
- const runtimeEnv: RuntimeEnv = {
7
- log: () => {},
8
- error: () => {},
9
- exit: (code: number): never => {
10
- throw new Error(`exit ${code}`);
11
- },
12
- };
7
+ const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv;
13
8
 
14
9
  it("lists peers and groups from config", async () => {
15
10
  const cfg = {
@@ -29,12 +24,10 @@ describe("msteams directory", () => {
29
24
  },
30
25
  } as unknown as OpenClawConfig;
31
26
 
32
- expect(msteamsPlugin.directory).toBeTruthy();
33
- expect(msteamsPlugin.directory?.listPeers).toBeTruthy();
34
- expect(msteamsPlugin.directory?.listGroups).toBeTruthy();
27
+ const directory = expectDirectorySurface(msteamsPlugin.directory);
35
28
 
36
29
  await expect(
37
- msteamsPlugin.directory!.listPeers!({
30
+ directory.listPeers({
38
31
  cfg,
39
32
  query: undefined,
40
33
  limit: undefined,
@@ -50,7 +43,7 @@ describe("msteams directory", () => {
50
43
  );
51
44
 
52
45
  await expect(
53
- msteamsPlugin.directory!.listGroups!({
46
+ directory.listGroups({
54
47
  cfg,
55
48
  query: undefined,
56
49
  limit: undefined,
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js";
3
+
4
+ describe("graph upload helpers", () => {
5
+ const tokenProvider = {
6
+ getAccessToken: vi.fn(async () => "graph-token"),
7
+ };
8
+
9
+ it("uploads to OneDrive with the personal drive path", async () => {
10
+ const fetchFn = vi.fn(
11
+ async () =>
12
+ new Response(
13
+ JSON.stringify({ id: "item-1", webUrl: "https://example.com/1", name: "a.txt" }),
14
+ {
15
+ status: 200,
16
+ headers: { "content-type": "application/json" },
17
+ },
18
+ ),
19
+ );
20
+
21
+ const result = await uploadToOneDrive({
22
+ buffer: Buffer.from("hello"),
23
+ filename: "a.txt",
24
+ tokenProvider,
25
+ fetchFn: fetchFn as typeof fetch,
26
+ });
27
+
28
+ expect(fetchFn).toHaveBeenCalledWith(
29
+ "https://graph.microsoft.com/v1.0/me/drive/root:/OpenClawShared/a.txt:/content",
30
+ expect.objectContaining({
31
+ method: "PUT",
32
+ headers: expect.objectContaining({
33
+ Authorization: "Bearer graph-token",
34
+ "Content-Type": "application/octet-stream",
35
+ }),
36
+ }),
37
+ );
38
+ expect(result).toEqual({
39
+ id: "item-1",
40
+ webUrl: "https://example.com/1",
41
+ name: "a.txt",
42
+ });
43
+ });
44
+
45
+ it("uploads to SharePoint with the site drive path", async () => {
46
+ const fetchFn = vi.fn(
47
+ async () =>
48
+ new Response(
49
+ JSON.stringify({ id: "item-2", webUrl: "https://example.com/2", name: "b.txt" }),
50
+ {
51
+ status: 200,
52
+ headers: { "content-type": "application/json" },
53
+ },
54
+ ),
55
+ );
56
+
57
+ const result = await uploadToSharePoint({
58
+ buffer: Buffer.from("world"),
59
+ filename: "b.txt",
60
+ siteId: "site-123",
61
+ tokenProvider,
62
+ fetchFn: fetchFn as typeof fetch,
63
+ });
64
+
65
+ expect(fetchFn).toHaveBeenCalledWith(
66
+ "https://graph.microsoft.com/v1.0/sites/site-123/drive/root:/OpenClawShared/b.txt:/content",
67
+ expect.objectContaining({
68
+ method: "PUT",
69
+ headers: expect.objectContaining({
70
+ Authorization: "Bearer graph-token",
71
+ "Content-Type": "application/octet-stream",
72
+ }),
73
+ }),
74
+ );
75
+ expect(result).toEqual({
76
+ id: "item-2",
77
+ webUrl: "https://example.com/2",
78
+ name: "b.txt",
79
+ });
80
+ });
81
+
82
+ it("rejects upload responses missing required fields", async () => {
83
+ const fetchFn = vi.fn(
84
+ async () =>
85
+ new Response(JSON.stringify({ id: "item-3" }), {
86
+ status: 200,
87
+ headers: { "content-type": "application/json" },
88
+ }),
89
+ );
90
+
91
+ await expect(
92
+ uploadToSharePoint({
93
+ buffer: Buffer.from("world"),
94
+ filename: "bad.txt",
95
+ siteId: "site-123",
96
+ tokenProvider,
97
+ fetchFn: fetchFn as typeof fetch,
98
+ }),
99
+ ).rejects.toThrow("SharePoint upload response missing required fields");
100
+ });
101
+ });
@@ -21,24 +21,34 @@ export interface OneDriveUploadResult {
21
21
  name: string;
22
22
  }
23
23
 
24
- /**
25
- * Upload a file to the user's OneDrive root folder.
26
- * For larger files, this uses the simple upload endpoint (up to 4MB).
27
- */
28
- export async function uploadToOneDrive(params: {
24
+ function parseUploadedDriveItem(
25
+ data: { id?: string; webUrl?: string; name?: string },
26
+ label: "OneDrive" | "SharePoint",
27
+ ): OneDriveUploadResult {
28
+ if (!data.id || !data.webUrl || !data.name) {
29
+ throw new Error(`${label} upload response missing required fields`);
30
+ }
31
+
32
+ return {
33
+ id: data.id,
34
+ webUrl: data.webUrl,
35
+ name: data.name,
36
+ };
37
+ }
38
+
39
+ async function uploadDriveItem(params: {
29
40
  buffer: Buffer;
30
41
  filename: string;
31
42
  contentType?: string;
32
43
  tokenProvider: MSTeamsAccessTokenProvider;
33
44
  fetchFn?: typeof fetch;
45
+ url: string;
46
+ label: "OneDrive" | "SharePoint";
34
47
  }): Promise<OneDriveUploadResult> {
35
48
  const fetchFn = params.fetchFn ?? fetch;
36
49
  const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
37
50
 
38
- // Use "OpenClawShared" folder to organize bot-uploaded files
39
- const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
40
-
41
- const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
51
+ const res = await fetchFn(params.url, {
42
52
  method: "PUT",
43
53
  headers: {
44
54
  Authorization: `Bearer ${token}`,
@@ -49,24 +59,33 @@ export async function uploadToOneDrive(params: {
49
59
 
50
60
  if (!res.ok) {
51
61
  const body = await res.text().catch(() => "");
52
- throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
62
+ throw new Error(`${params.label} upload failed: ${res.status} ${res.statusText} - ${body}`);
53
63
  }
54
64
 
55
- const data = (await res.json()) as {
56
- id?: string;
57
- webUrl?: string;
58
- name?: string;
59
- };
60
-
61
- if (!data.id || !data.webUrl || !data.name) {
62
- throw new Error("OneDrive upload response missing required fields");
63
- }
65
+ return parseUploadedDriveItem(
66
+ (await res.json()) as { id?: string; webUrl?: string; name?: string },
67
+ params.label,
68
+ );
69
+ }
64
70
 
65
- return {
66
- id: data.id,
67
- webUrl: data.webUrl,
68
- name: data.name,
69
- };
71
+ /**
72
+ * Upload a file to the user's OneDrive root folder.
73
+ * For larger files, this uses the simple upload endpoint (up to 4MB).
74
+ */
75
+ export async function uploadToOneDrive(params: {
76
+ buffer: Buffer;
77
+ filename: string;
78
+ contentType?: string;
79
+ tokenProvider: MSTeamsAccessTokenProvider;
80
+ fetchFn?: typeof fetch;
81
+ }): Promise<OneDriveUploadResult> {
82
+ // Use "OpenClawShared" folder to organize bot-uploaded files
83
+ const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
84
+ return await uploadDriveItem({
85
+ ...params,
86
+ url: `${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`,
87
+ label: "OneDrive",
88
+ });
70
89
  }
71
90
 
72
91
  export interface OneDriveSharingLink {
@@ -175,44 +194,13 @@ export async function uploadToSharePoint(params: {
175
194
  siteId: string;
176
195
  fetchFn?: typeof fetch;
177
196
  }): Promise<OneDriveUploadResult> {
178
- const fetchFn = params.fetchFn ?? fetch;
179
- const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
180
-
181
197
  // Use "OpenClawShared" folder to organize bot-uploaded files
182
198
  const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
183
-
184
- const res = await fetchFn(
185
- `${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`,
186
- {
187
- method: "PUT",
188
- headers: {
189
- Authorization: `Bearer ${token}`,
190
- "Content-Type": params.contentType ?? "application/octet-stream",
191
- },
192
- body: new Uint8Array(params.buffer),
193
- },
194
- );
195
-
196
- if (!res.ok) {
197
- const body = await res.text().catch(() => "");
198
- throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
199
- }
200
-
201
- const data = (await res.json()) as {
202
- id?: string;
203
- webUrl?: string;
204
- name?: string;
205
- };
206
-
207
- if (!data.id || !data.webUrl || !data.name) {
208
- throw new Error("SharePoint upload response missing required fields");
209
- }
210
-
211
- return {
212
- id: data.id,
213
- webUrl: data.webUrl,
214
- name: data.name,
215
- };
199
+ return await uploadDriveItem({
200
+ ...params,
201
+ url: `${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`,
202
+ label: "SharePoint",
203
+ });
216
204
  }
217
205
 
218
206
  export interface ChatMember {
@@ -139,6 +139,22 @@ describe("msteams messenger", () => {
139
139
  });
140
140
 
141
141
  describe("sendMSTeamsMessages", () => {
142
+ function createRevokedThreadContext(params?: { failAfterAttempt?: number; sent?: string[] }) {
143
+ let attempt = 0;
144
+ return {
145
+ sendActivity: async (activity: unknown) => {
146
+ const { text } = activity as { text?: string };
147
+ const content = text ?? "";
148
+ attempt += 1;
149
+ if (params?.failAfterAttempt && attempt < params.failAfterAttempt) {
150
+ params.sent?.push(content);
151
+ return { id: `id:${content}` };
152
+ }
153
+ throw new TypeError(REVOCATION_ERROR);
154
+ },
155
+ };
156
+ }
157
+
142
158
  const baseRef: StoredConversationReference = {
143
159
  activityId: "activity123",
144
160
  user: { id: "user123", name: "User" },
@@ -305,13 +321,7 @@ describe("msteams messenger", () => {
305
321
 
306
322
  it("falls back to proactive messaging when thread context is revoked", async () => {
307
323
  const proactiveSent: string[] = [];
308
-
309
- const ctx = {
310
- sendActivity: async () => {
311
- throw new TypeError(REVOCATION_ERROR);
312
- },
313
- };
314
-
324
+ const ctx = createRevokedThreadContext();
315
325
  const adapter = createFallbackAdapter(proactiveSent);
316
326
 
317
327
  const ids = await sendMSTeamsMessages({
@@ -331,21 +341,7 @@ describe("msteams messenger", () => {
331
341
  it("falls back only for remaining thread messages after context revocation", async () => {
332
342
  const threadSent: string[] = [];
333
343
  const proactiveSent: string[] = [];
334
- let attempt = 0;
335
-
336
- const ctx = {
337
- sendActivity: async (activity: unknown) => {
338
- const { text } = activity as { text?: string };
339
- const content = text ?? "";
340
- attempt += 1;
341
- if (attempt === 1) {
342
- threadSent.push(content);
343
- return { id: `id:${content}` };
344
- }
345
- throw new TypeError(REVOCATION_ERROR);
346
- },
347
- };
348
-
344
+ const ctx = createRevokedThreadContext({ failAfterAttempt: 2, sent: threadSent });
349
345
  const adapter = createFallbackAdapter(proactiveSent);
350
346
 
351
347
  const ids = await sendMSTeamsMessages({
@@ -9,7 +9,7 @@ import {
9
9
  evaluateSenderGroupAccessForPolicy,
10
10
  resolveSenderScopedGroupPolicy,
11
11
  recordPendingHistoryEntryIfEnabled,
12
- resolveControlCommandGate,
12
+ resolveDualTextControlCommandGate,
13
13
  resolveDefaultGroupPolicy,
14
14
  isDangerousNameMatchingEnabled,
15
15
  readStoreAllowFromForDmPolicy,
@@ -175,6 +175,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
175
175
  teamName,
176
176
  conversationId,
177
177
  channelName,
178
+ allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
178
179
  });
179
180
  const senderGroupPolicy = resolveSenderScopedGroupPolicy({
180
181
  groupPolicy,
@@ -296,18 +297,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
296
297
  senderName,
297
298
  allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
298
299
  });
299
- const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
300
- const commandGate = resolveControlCommandGate({
300
+ const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({
301
301
  useAccessGroups,
302
- authorizers: [
303
- { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
304
- { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
305
- ],
306
- allowTextCommands: true,
307
- hasControlCommand: hasControlCommandInMessage,
302
+ primaryConfigured: commandDmAllowFrom.length > 0,
303
+ primaryAllowed: ownerAllowedForCommands,
304
+ secondaryConfigured: effectiveGroupAllowFrom.length > 0,
305
+ secondaryAllowed: groupAllowedForCommands,
306
+ hasControlCommand: core.channel.text.hasControlCommand(text, cfg),
308
307
  });
309
- const commandAuthorized = commandGate.commandAuthorized;
310
- if (commandGate.shouldBlock) {
308
+ if (shouldBlock) {
311
309
  logInboundDrop({
312
310
  log: logVerboseMessage,
313
311
  channel: "msteams",
@@ -123,6 +123,26 @@ function createInvokeContext(params: {
123
123
  };
124
124
  }
125
125
 
126
+ function createConsentInvokeHarness(params: {
127
+ pendingConversationId?: string;
128
+ invokeConversationId: string;
129
+ action: "accept" | "decline";
130
+ }) {
131
+ const uploadId = storePendingUpload({
132
+ buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
133
+ filename: "secret.txt",
134
+ contentType: "text/plain",
135
+ conversationId: params.pendingConversationId ?? "19:victim@thread.v2",
136
+ });
137
+ const handler = registerMSTeamsHandlers(createActivityHandler(), createDeps());
138
+ const { context, sendActivity } = createInvokeContext({
139
+ conversationId: params.invokeConversationId,
140
+ uploadId,
141
+ action: params.action,
142
+ });
143
+ return { uploadId, handler, context, sendActivity };
144
+ }
145
+
126
146
  describe("msteams file consent invoke authz", () => {
127
147
  beforeEach(() => {
128
148
  setMSTeamsRuntime(runtimeStub);
@@ -132,17 +152,8 @@ describe("msteams file consent invoke authz", () => {
132
152
  });
133
153
 
134
154
  it("uploads when invoke conversation matches pending upload conversation", async () => {
135
- const uploadId = storePendingUpload({
136
- buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
137
- filename: "secret.txt",
138
- contentType: "text/plain",
139
- conversationId: "19:victim@thread.v2",
140
- });
141
- const deps = createDeps();
142
- const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
143
- const { context, sendActivity } = createInvokeContext({
144
- conversationId: "19:victim@thread.v2;messageid=abc123",
145
- uploadId,
155
+ const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({
156
+ invokeConversationId: "19:victim@thread.v2;messageid=abc123",
146
157
  action: "accept",
147
158
  });
148
159
 
@@ -166,17 +177,8 @@ describe("msteams file consent invoke authz", () => {
166
177
  });
167
178
 
168
179
  it("rejects cross-conversation accept invoke and keeps pending upload", async () => {
169
- const uploadId = storePendingUpload({
170
- buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
171
- filename: "secret.txt",
172
- contentType: "text/plain",
173
- conversationId: "19:victim@thread.v2",
174
- });
175
- const deps = createDeps();
176
- const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
177
- const { context, sendActivity } = createInvokeContext({
178
- conversationId: "19:attacker@thread.v2",
179
- uploadId,
180
+ const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({
181
+ invokeConversationId: "19:attacker@thread.v2",
180
182
  action: "accept",
181
183
  });
182
184
 
@@ -198,17 +200,8 @@ describe("msteams file consent invoke authz", () => {
198
200
  });
199
201
 
200
202
  it("ignores cross-conversation decline invoke and keeps pending upload", async () => {
201
- const uploadId = storePendingUpload({
202
- buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
203
- filename: "secret.txt",
204
- contentType: "text/plain",
205
- conversationId: "19:victim@thread.v2",
206
- });
207
- const deps = createDeps();
208
- const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
209
- const { context, sendActivity } = createInvokeContext({
210
- conversationId: "19:attacker@thread.v2",
211
- uploadId,
203
+ const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({
204
+ invokeConversationId: "19:attacker@thread.v2",
212
205
  action: "decline",
213
206
  });
214
207
 
@@ -6,6 +6,27 @@ import {
6
6
  resolveMSTeamsRouteConfig,
7
7
  } from "./policy.js";
8
8
 
9
+ function resolveNamedTeamRouteConfig(allowNameMatching = false) {
10
+ const cfg: MSTeamsConfig = {
11
+ teams: {
12
+ "My Team": {
13
+ requireMention: true,
14
+ channels: {
15
+ "General Chat": { requireMention: false },
16
+ },
17
+ },
18
+ },
19
+ };
20
+
21
+ return resolveMSTeamsRouteConfig({
22
+ cfg,
23
+ teamName: "My Team",
24
+ channelName: "General Chat",
25
+ conversationId: "ignored",
26
+ allowNameMatching,
27
+ });
28
+ }
29
+
9
30
  describe("msteams policy", () => {
10
31
  describe("resolveMSTeamsRouteConfig", () => {
11
32
  it("returns team and channel config when present", () => {
@@ -50,24 +71,16 @@ describe("msteams policy", () => {
50
71
  expect(res.allowed).toBe(false);
51
72
  });
52
73
 
53
- it("matches team and channel by name", () => {
54
- const cfg: MSTeamsConfig = {
55
- teams: {
56
- "My Team": {
57
- requireMention: true,
58
- channels: {
59
- "General Chat": { requireMention: false },
60
- },
61
- },
62
- },
63
- };
74
+ it("blocks team and channel name matches by default", () => {
75
+ const res = resolveNamedTeamRouteConfig();
64
76
 
65
- const res = resolveMSTeamsRouteConfig({
66
- cfg,
67
- teamName: "My Team",
68
- channelName: "General Chat",
69
- conversationId: "ignored",
70
- });
77
+ expect(res.teamConfig).toBeUndefined();
78
+ expect(res.channelConfig).toBeUndefined();
79
+ expect(res.allowed).toBe(false);
80
+ });
81
+
82
+ it("matches team and channel by name when dangerous name matching is enabled", () => {
83
+ const res = resolveNamedTeamRouteConfig(true);
71
84
 
72
85
  expect(res.teamConfig?.requireMention).toBe(true);
73
86
  expect(res.channelConfig?.requireMention).toBe(false);
package/src/policy.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  resolveToolsBySender,
17
17
  resolveChannelEntryMatchWithFallback,
18
18
  resolveNestedAllowlistDecision,
19
+ isDangerousNameMatchingEnabled,
19
20
  } from "openclaw/plugin-sdk/msteams";
20
21
 
21
22
  export type MSTeamsResolvedRouteConfig = {
@@ -35,6 +36,7 @@ export function resolveMSTeamsRouteConfig(params: {
35
36
  teamName?: string | null | undefined;
36
37
  conversationId?: string | null | undefined;
37
38
  channelName?: string | null | undefined;
39
+ allowNameMatching?: boolean;
38
40
  }): MSTeamsResolvedRouteConfig {
39
41
  const teamId = params.teamId?.trim();
40
42
  const teamName = params.teamName?.trim();
@@ -44,8 +46,8 @@ export function resolveMSTeamsRouteConfig(params: {
44
46
  const allowlistConfigured = Object.keys(teams).length > 0;
45
47
  const teamCandidates = buildChannelKeyCandidates(
46
48
  teamId,
47
- teamName,
48
- teamName ? normalizeChannelSlug(teamName) : undefined,
49
+ params.allowNameMatching ? teamName : undefined,
50
+ params.allowNameMatching && teamName ? normalizeChannelSlug(teamName) : undefined,
49
51
  );
50
52
  const teamMatch = resolveChannelEntryMatchWithFallback({
51
53
  entries: teams,
@@ -58,8 +60,8 @@ export function resolveMSTeamsRouteConfig(params: {
58
60
  const channelAllowlistConfigured = Object.keys(channels).length > 0;
59
61
  const channelCandidates = buildChannelKeyCandidates(
60
62
  conversationId,
61
- channelName,
62
- channelName ? normalizeChannelSlug(channelName) : undefined,
63
+ params.allowNameMatching ? channelName : undefined,
64
+ params.allowNameMatching && channelName ? normalizeChannelSlug(channelName) : undefined,
63
65
  );
64
66
  const channelMatch = resolveChannelEntryMatchWithFallback({
65
67
  entries: channels,
@@ -101,6 +103,7 @@ export function resolveMSTeamsGroupToolPolicy(
101
103
  const groupId = params.groupId?.trim();
102
104
  const groupChannel = params.groupChannel?.trim();
103
105
  const groupSpace = params.groupSpace?.trim();
106
+ const allowNameMatching = isDangerousNameMatchingEnabled(cfg);
104
107
 
105
108
  const resolved = resolveMSTeamsRouteConfig({
106
109
  cfg,
@@ -108,6 +111,7 @@ export function resolveMSTeamsGroupToolPolicy(
108
111
  teamName: groupSpace,
109
112
  conversationId: groupId,
110
113
  channelName: groupChannel,
114
+ allowNameMatching,
111
115
  });
112
116
 
113
117
  if (resolved.channelConfig) {
@@ -158,8 +162,8 @@ export function resolveMSTeamsGroupToolPolicy(
158
162
 
159
163
  const channelCandidates = buildChannelKeyCandidates(
160
164
  groupId,
161
- groupChannel,
162
- groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
165
+ allowNameMatching ? groupChannel : undefined,
166
+ allowNameMatching && groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
163
167
  );
164
168
  for (const teamConfig of Object.values(cfg.teams ?? {})) {
165
169
  const match = resolveChannelEntryMatchWithFallback({