@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 +13 -0
- package/package.json +1 -1
- package/src/attachments/shared.test.ts +23 -8
- package/src/attachments.test.ts +12 -20
- package/src/channel.directory.test.ts +5 -12
- package/src/graph-upload.test.ts +101 -0
- package/src/graph-upload.ts +48 -60
- package/src/messenger.test.ts +18 -22
- package/src/monitor-handler/message-handler.ts +9 -11
- package/src/monitor-handler.file-consent.test.ts +26 -33
- package/src/policy.test.ts +30 -17
- package/src/policy.ts +10 -6
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
|
@@ -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
|
-
|
|
141
|
+
await expectSafeFetchStatus({
|
|
142
|
+
fetchMock,
|
|
125
143
|
url: "https://teams.sharepoint.com/file.pdf",
|
|
126
144
|
allowHosts: ["sharepoint.com"],
|
|
127
|
-
|
|
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
|
-
|
|
156
|
+
await expectSafeFetchStatus({
|
|
157
|
+
fetchMock,
|
|
141
158
|
url: "https://teams.sharepoint.com/file.pdf",
|
|
142
159
|
allowHosts: ["sharepoint.com"],
|
|
143
|
-
|
|
144
|
-
resolveFn: publicResolve,
|
|
160
|
+
expectedStatus: 200,
|
|
145
161
|
});
|
|
146
|
-
expect(res.status).toBe(200);
|
|
147
162
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
148
163
|
});
|
|
149
164
|
|
package/src/attachments.test.ts
CHANGED
|
@@ -88,14 +88,17 @@ function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean {
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/graph-upload.ts
CHANGED
|
@@ -21,24 +21,34 @@ export interface OneDriveUploadResult {
|
|
|
21
21
|
name: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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(
|
|
62
|
+
throw new Error(`${params.label} upload failed: ${res.status} ${res.statusText} - ${body}`);
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
id?: string;
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
185
|
-
`${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`,
|
|
186
|
-
|
|
187
|
-
|
|
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 {
|
package/src/messenger.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
300
|
-
const commandGate = resolveControlCommandGate({
|
|
300
|
+
const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({
|
|
301
301
|
useAccessGroups,
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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 =
|
|
136
|
-
|
|
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 =
|
|
170
|
-
|
|
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 =
|
|
202
|
-
|
|
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
|
|
package/src/policy.test.ts
CHANGED
|
@@ -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("
|
|
54
|
-
const
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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({
|