switchroom 0.13.53 → 0.13.55
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/dist/agent-scheduler/index.js +53 -1
- package/dist/auth-broker/index.js +53 -1
- package/dist/cli/ms-365-write-pretool.mjs +259 -0
- package/dist/cli/notion-write-pretool.mjs +13388 -0
- package/dist/cli/switchroom.js +1601 -380
- package/dist/host-control/main.js +53 -1
- package/dist/vault/approvals/kernel-server.js +54 -2
- package/dist/vault/broker/server.js +54 -2
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +17 -0
- package/profiles/_shared/telegram-style.md.hbs +2 -0
- package/skills/notion/SKILL.md +144 -0
- package/telegram-plugin/dist/gateway/gateway.js +406 -43
- package/telegram-plugin/gateway/gateway.ts +227 -17
- package/telegram-plugin/gateway/ipc-protocol.ts +37 -0
- package/telegram-plugin/gateway/ipc-server.ts +59 -0
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +314 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +335 -0
- package/telegram-plugin/tests/ipc-validator.test.ts +61 -0
- package/telegram-plugin/tests/slash-command-smart-split.test.ts +115 -0
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +35 -0
- package/vendor/hindsight-memory/scripts/recall.py +164 -4
- package/vendor/hindsight-memory/scripts/retain.py +52 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +42 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_topic_filter.py +139 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ms365-write-approval handler — RFC #1873 §8 PR 4.
|
|
3
|
+
* Mirrors `drive-write-approval.test.ts` shape; DI-style — no live
|
|
4
|
+
* kernel / grammy / IPC needed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
buildMs365CardText,
|
|
11
|
+
handleRequestMs365Approval,
|
|
12
|
+
validateMs365Preview,
|
|
13
|
+
type Ms365ApprovalHandlerDeps,
|
|
14
|
+
type Ms365WritePreview,
|
|
15
|
+
} from "./ms365-write-approval.js";
|
|
16
|
+
import type { IpcClient } from "./ipc-server.js";
|
|
17
|
+
import type { RequestMs365ApprovalMessage } from "./ipc-protocol.js";
|
|
18
|
+
|
|
19
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// validateMs365Preview
|
|
21
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
describe("validateMs365Preview", () => {
|
|
24
|
+
const validPreview = {
|
|
25
|
+
agentName: "clerk",
|
|
26
|
+
toolName: "mcp__ms-365__upload-file-content",
|
|
27
|
+
itemId: "01ABCDEFG",
|
|
28
|
+
itemDisplayName: "Q3-Strategy.docx",
|
|
29
|
+
accountEmail: "ken@outlook.com",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
it("accepts a minimal valid preview", () => {
|
|
33
|
+
const r = validateMs365Preview(validPreview);
|
|
34
|
+
expect(r).not.toBeNull();
|
|
35
|
+
expect(r!.agentName).toBe("clerk");
|
|
36
|
+
expect(r!.toolName).toBe("mcp__ms-365__upload-file-content");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accepts optional fields when typed correctly", () => {
|
|
40
|
+
const r = validateMs365Preview({
|
|
41
|
+
...validPreview,
|
|
42
|
+
deepLink: "https://onedrive.live.com/x",
|
|
43
|
+
sizeBytesBefore: 1024,
|
|
44
|
+
sizeBytesAfter: 2048,
|
|
45
|
+
agentRationale: "Add Q3 meeting notes",
|
|
46
|
+
});
|
|
47
|
+
expect(r).not.toBeNull();
|
|
48
|
+
expect(r!.deepLink).toBe("https://onedrive.live.com/x");
|
|
49
|
+
expect(r!.sizeBytesBefore).toBe(1024);
|
|
50
|
+
expect(r!.sizeBytesAfter).toBe(2048);
|
|
51
|
+
expect(r!.agentRationale).toBe("Add Q3 meeting notes");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects null / non-object", () => {
|
|
55
|
+
expect(validateMs365Preview(null)).toBeNull();
|
|
56
|
+
expect(validateMs365Preview("string")).toBeNull();
|
|
57
|
+
expect(validateMs365Preview(42)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects missing required fields", () => {
|
|
61
|
+
const cases = ["agentName", "toolName", "itemId", "itemDisplayName", "accountEmail"];
|
|
62
|
+
for (const field of cases) {
|
|
63
|
+
const copy: Record<string, unknown> = { ...validPreview };
|
|
64
|
+
delete copy[field];
|
|
65
|
+
expect(validateMs365Preview(copy)).toBeNull();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects empty-string required fields", () => {
|
|
70
|
+
const r = validateMs365Preview({ ...validPreview, agentName: "" });
|
|
71
|
+
expect(r).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("ignores wrong-type optional fields gracefully (drops them, doesn't fail)", () => {
|
|
75
|
+
const r = validateMs365Preview({
|
|
76
|
+
...validPreview,
|
|
77
|
+
deepLink: 42, // wrong type
|
|
78
|
+
sizeBytesAfter: "not-a-number",
|
|
79
|
+
});
|
|
80
|
+
expect(r).not.toBeNull();
|
|
81
|
+
expect(r!.deepLink).toBeUndefined();
|
|
82
|
+
expect(r!.sizeBytesAfter).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
87
|
+
// buildMs365CardText
|
|
88
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe("buildMs365CardText", () => {
|
|
91
|
+
const base: Ms365WritePreview = {
|
|
92
|
+
agentName: "clerk",
|
|
93
|
+
toolName: "mcp__ms-365__upload-file-content",
|
|
94
|
+
itemId: "01ABCDEFG",
|
|
95
|
+
itemDisplayName: "Q3-Strategy.docx",
|
|
96
|
+
accountEmail: "ken@outlook.com",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
it("includes agent, tool, item, account", () => {
|
|
100
|
+
const text = buildMs365CardText(base);
|
|
101
|
+
expect(text).toContain("clerk");
|
|
102
|
+
expect(text).toContain("ms-365__upload-file-content");
|
|
103
|
+
expect(text).toContain("Q3-Strategy.docx");
|
|
104
|
+
expect(text).toContain("01ABCDEFG");
|
|
105
|
+
expect(text).toContain("ken@outlook.com");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("omits ID line for new files", () => {
|
|
109
|
+
const text = buildMs365CardText({ ...base, itemId: "(new)" });
|
|
110
|
+
expect(text).not.toMatch(/^ID:/m);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("shows size delta when before/after present", () => {
|
|
114
|
+
const text = buildMs365CardText({ ...base, sizeBytesBefore: 1024, sizeBytesAfter: 2048 });
|
|
115
|
+
expect(text).toMatch(/Size:.*1\.0KB.*2\.0KB.*\+1\.0KB/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("shows negative delta correctly", () => {
|
|
119
|
+
const text = buildMs365CardText({ ...base, sizeBytesBefore: 2048, sizeBytesAfter: 1024 });
|
|
120
|
+
expect(text).toMatch(/Size:.*-1\.0KB/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("includes deepLink when present", () => {
|
|
124
|
+
const text = buildMs365CardText({ ...base, deepLink: "https://onedrive.live.com/x" });
|
|
125
|
+
expect(text).toContain("https://onedrive.live.com/x");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("includes agent rationale when present", () => {
|
|
129
|
+
const text = buildMs365CardText({ ...base, agentRationale: "Adding meeting notes" });
|
|
130
|
+
expect(text).toContain("💬 Adding meeting notes");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("always shows weak-attestation warning", () => {
|
|
134
|
+
const text = buildMs365CardText(base);
|
|
135
|
+
expect(text).toContain("Weak attestation (RFC §8 v1)");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("truncates over-long display names with ellipsis", () => {
|
|
139
|
+
const text = buildMs365CardText({ ...base, itemDisplayName: "x".repeat(500) });
|
|
140
|
+
expect(text).toContain("…");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
145
|
+
// handleRequestMs365Approval — DI integration
|
|
146
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function makeDeps(): {
|
|
149
|
+
deps: Ms365ApprovalHandlerDeps;
|
|
150
|
+
registerSpy: ReturnType<typeof vi.fn>;
|
|
151
|
+
postCardSpy: ReturnType<typeof vi.fn>;
|
|
152
|
+
client: { send: ReturnType<typeof vi.fn> };
|
|
153
|
+
} {
|
|
154
|
+
const registerSpy = vi.fn().mockResolvedValue({
|
|
155
|
+
request_id: "aabbccdd11223344aabbccdd11223344",
|
|
156
|
+
expires_at_ms: Date.now() + 5 * 60 * 1000,
|
|
157
|
+
});
|
|
158
|
+
const postCardSpy = vi.fn().mockResolvedValue({ messageId: 42 });
|
|
159
|
+
const sendSpy = vi.fn();
|
|
160
|
+
const deps: Ms365ApprovalHandlerDeps = {
|
|
161
|
+
agentName: "clerk",
|
|
162
|
+
loadAllowFrom: () => ["12345"],
|
|
163
|
+
loadTargetChat: () => ({ chatId: "12345" }),
|
|
164
|
+
registerApproval: registerSpy,
|
|
165
|
+
postCard: postCardSpy,
|
|
166
|
+
log: () => {},
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
deps,
|
|
170
|
+
registerSpy,
|
|
171
|
+
postCardSpy,
|
|
172
|
+
client: { send: sendSpy },
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function makeMsg(overrides: Partial<RequestMs365ApprovalMessage> = {}): RequestMs365ApprovalMessage {
|
|
177
|
+
return {
|
|
178
|
+
type: "request_ms365_approval",
|
|
179
|
+
correlationId: "corr-1",
|
|
180
|
+
agentName: "clerk",
|
|
181
|
+
preview: {
|
|
182
|
+
agentName: "clerk",
|
|
183
|
+
toolName: "mcp__ms-365__upload-file-content",
|
|
184
|
+
itemId: "01ABC",
|
|
185
|
+
itemDisplayName: "Strategy.docx",
|
|
186
|
+
accountEmail: "ken@outlook.com",
|
|
187
|
+
},
|
|
188
|
+
ttlMs: 5 * 60 * 1000,
|
|
189
|
+
...overrides,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
describe("handleRequestMs365Approval", () => {
|
|
194
|
+
it("happy path: registers kernel, posts card, sends ok=true response", async () => {
|
|
195
|
+
const { deps, registerSpy, postCardSpy, client } = makeDeps();
|
|
196
|
+
await handleRequestMs365Approval(client as unknown as IpcClient, makeMsg(), deps);
|
|
197
|
+
expect(registerSpy).toHaveBeenCalledOnce();
|
|
198
|
+
expect(postCardSpy).toHaveBeenCalledOnce();
|
|
199
|
+
expect(client.send).toHaveBeenCalledWith(
|
|
200
|
+
expect.objectContaining({
|
|
201
|
+
type: "ms365_approval_posted",
|
|
202
|
+
correlationId: "corr-1",
|
|
203
|
+
ok: true,
|
|
204
|
+
requestId: "aabbccdd11223344aabbccdd11223344",
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("uses correct kernel scope ms-365:write:<itemId>", async () => {
|
|
210
|
+
const { deps, registerSpy, client } = makeDeps();
|
|
211
|
+
await handleRequestMs365Approval(
|
|
212
|
+
client as unknown as IpcClient,
|
|
213
|
+
makeMsg({ preview: { ...makeMsg().preview, itemId: "01XYZ" } }),
|
|
214
|
+
deps,
|
|
215
|
+
);
|
|
216
|
+
expect(registerSpy.mock.calls[0][0].scope).toBe("ms-365:write:01XYZ");
|
|
217
|
+
expect(registerSpy.mock.calls[0][0].action).toBe("write");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("rejects cross-agent requests", async () => {
|
|
221
|
+
const { deps, registerSpy, client } = makeDeps();
|
|
222
|
+
await handleRequestMs365Approval(
|
|
223
|
+
client as unknown as IpcClient,
|
|
224
|
+
makeMsg({ agentName: "other-agent" }),
|
|
225
|
+
deps,
|
|
226
|
+
);
|
|
227
|
+
expect(registerSpy).not.toHaveBeenCalled();
|
|
228
|
+
expect(client.send).toHaveBeenCalledWith(
|
|
229
|
+
expect.objectContaining({
|
|
230
|
+
ok: false,
|
|
231
|
+
reason: expect.stringContaining("cross-agent"),
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("rejects invalid preview", async () => {
|
|
237
|
+
const { deps, registerSpy, client } = makeDeps();
|
|
238
|
+
await handleRequestMs365Approval(
|
|
239
|
+
client as unknown as IpcClient,
|
|
240
|
+
makeMsg({ preview: { agentName: "clerk" } }),
|
|
241
|
+
deps,
|
|
242
|
+
);
|
|
243
|
+
expect(registerSpy).not.toHaveBeenCalled();
|
|
244
|
+
expect(client.send).toHaveBeenCalledWith(
|
|
245
|
+
expect.objectContaining({ ok: false, reason: "invalid preview payload" }),
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("rejects when no allowFrom is configured", async () => {
|
|
250
|
+
const { deps, client } = makeDeps();
|
|
251
|
+
deps.loadAllowFrom = () => [];
|
|
252
|
+
await handleRequestMs365Approval(client as unknown as IpcClient, makeMsg(), deps);
|
|
253
|
+
expect(client.send).toHaveBeenCalledWith(
|
|
254
|
+
expect.objectContaining({ ok: false, reason: expect.stringContaining("allowFrom") }),
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("rejects when no target chat resolved", async () => {
|
|
259
|
+
const { deps, client } = makeDeps();
|
|
260
|
+
deps.loadTargetChat = () => null;
|
|
261
|
+
await handleRequestMs365Approval(client as unknown as IpcClient, makeMsg(), deps);
|
|
262
|
+
expect(client.send).toHaveBeenCalledWith(
|
|
263
|
+
expect.objectContaining({ ok: false, reason: expect.stringContaining("target chat") }),
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("fail-closes when kernel registration returns null", async () => {
|
|
268
|
+
const { deps, client } = makeDeps();
|
|
269
|
+
deps.registerApproval = async () => null;
|
|
270
|
+
await handleRequestMs365Approval(client as unknown as IpcClient, makeMsg(), deps);
|
|
271
|
+
expect(client.send).toHaveBeenCalledWith(
|
|
272
|
+
expect.objectContaining({ ok: false, reason: expect.stringContaining("kernel") }),
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("fail-closes when card post returns null", async () => {
|
|
277
|
+
const { deps, client } = makeDeps();
|
|
278
|
+
deps.postCard = async () => null;
|
|
279
|
+
await handleRequestMs365Approval(client as unknown as IpcClient, makeMsg(), deps);
|
|
280
|
+
expect(client.send).toHaveBeenCalledWith(
|
|
281
|
+
expect.objectContaining({ ok: false, reason: expect.stringContaining("card post") }),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("clamps oversized TTL to maxTtlMs", async () => {
|
|
286
|
+
const { deps, registerSpy, client } = makeDeps();
|
|
287
|
+
await handleRequestMs365Approval(
|
|
288
|
+
client as unknown as IpcClient,
|
|
289
|
+
makeMsg({ ttlMs: 60 * 60 * 1000 }), // 1h, max is 30min
|
|
290
|
+
deps,
|
|
291
|
+
);
|
|
292
|
+
expect(registerSpy.mock.calls[0][0].ttl_ms).toBe(30 * 60 * 1000);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("uses default TTL when none provided", async () => {
|
|
296
|
+
const { deps, registerSpy, client } = makeDeps();
|
|
297
|
+
const msg = makeMsg();
|
|
298
|
+
delete (msg as { ttlMs?: number }).ttlMs;
|
|
299
|
+
await handleRequestMs365Approval(client as unknown as IpcClient, msg, deps);
|
|
300
|
+
expect(registerSpy.mock.calls[0][0].ttl_ms).toBe(5 * 60 * 1000);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("emits apv:<requestId>:once|deny callback data (kernel-generic, NOT a ms365: dead-end)", async () => {
|
|
304
|
+
const { deps, postCardSpy, client } = makeDeps();
|
|
305
|
+
await handleRequestMs365Approval(client as unknown as IpcClient, makeMsg(), deps);
|
|
306
|
+
const kb = postCardSpy.mock.calls[0][0].replyMarkup as {
|
|
307
|
+
inline_keyboard: Array<Array<{ callback_data: string }>>;
|
|
308
|
+
};
|
|
309
|
+
// MUST be `apv:` so the existing gateway dispatcher handles taps.
|
|
310
|
+
// Provider-namespaced `ms365:` would silently drop button taps.
|
|
311
|
+
expect(kb.inline_keyboard[0][0].callback_data).toMatch(/^apv:.+:once$/);
|
|
312
|
+
expect(kb.inline_keyboard[0][1].callback_data).toMatch(/^apv:.+:deny$/);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft 365 write approval handler — RFC #1873 §8 PR 4.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `drive-write-approval.ts` shape but with the weak-metadata
|
|
5
|
+
* v1 preview (file path / item ID / size delta / deep link / agent
|
|
6
|
+
* rationale) instead of Google's full DiffPreviewInput.
|
|
7
|
+
*
|
|
8
|
+
* Flow (called by the gateway's IPC dispatcher when the
|
|
9
|
+
* `ms-365-write-pretool` hook sends `request_ms365_approval`):
|
|
10
|
+
* 1. Validate the inbound preview payload (`validateMs365Preview`).
|
|
11
|
+
* 2. Verify the request is for this gateway's agent (cross-agent
|
|
12
|
+
* requests rejected — defense in depth).
|
|
13
|
+
* 3. Register a kernel approval at scope `ms-365:write:<itemId>`,
|
|
14
|
+
* action `write`, approver_set = operator allowFrom.
|
|
15
|
+
* 4. Build a plain-text Telegram card showing: tool, target file/
|
|
16
|
+
* item, size delta, deep link, agent's rationale.
|
|
17
|
+
* 5. Post the card to operator chat.
|
|
18
|
+
* 6. Send `ms365_approval_posted { ok, requestId, expiresAtMs }` back
|
|
19
|
+
* over IPC so the hook can poll `approval_lookup` for verdict.
|
|
20
|
+
*
|
|
21
|
+
* Fail closed: on any failure (preview malformed, kernel down, card
|
|
22
|
+
* post fails) we send `ok: false` so the hook fails closed and blocks
|
|
23
|
+
* the tool call.
|
|
24
|
+
*
|
|
25
|
+
* **Why weak metadata v1** — softeria upload calls are full-file
|
|
26
|
+
* replacements; computing a structural diff would require downloading
|
|
27
|
+
* the prior version and running the docx/xlsx/pptx skill in diff mode.
|
|
28
|
+
* That's RFC §8 v1.5 work; v1 ships the simpler "operator must trust
|
|
29
|
+
* the agent's rationale + click through to OneDrive to verify" shape.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { IpcClient } from "./ipc-server.js";
|
|
33
|
+
import type { RequestMs365ApprovalMessage } from "./ipc-protocol.js";
|
|
34
|
+
|
|
35
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Wire shape — validates an inbound preview payload
|
|
37
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface Ms365WritePreview {
|
|
40
|
+
/** Agent slug — appears on the card title. */
|
|
41
|
+
agentName: string;
|
|
42
|
+
/** Tool name as Claude Code sees it: `mcp__ms-365__<fn>`. */
|
|
43
|
+
toolName: string;
|
|
44
|
+
/**
|
|
45
|
+
* Item identifier for the OneDrive item / mail message / calendar
|
|
46
|
+
* event the tool will mutate. Used as the kernel scope key:
|
|
47
|
+
* `ms-365:write:<itemId>`. May be "(new)" for create operations.
|
|
48
|
+
*/
|
|
49
|
+
itemId: string;
|
|
50
|
+
/** Display name (file name / mail subject / event title). */
|
|
51
|
+
itemDisplayName: string;
|
|
52
|
+
/** Microsoft account email the agent is authenticated as. */
|
|
53
|
+
accountEmail: string;
|
|
54
|
+
/** Deep link to OneDrive / Outlook web — best-effort, may be empty. */
|
|
55
|
+
deepLink?: string;
|
|
56
|
+
/** Byte delta — present only for OneDrive uploads with known sizes. */
|
|
57
|
+
sizeBytesBefore?: number;
|
|
58
|
+
sizeBytesAfter?: number;
|
|
59
|
+
/** 1-line agent rationale — advisory; operator should not over-trust. */
|
|
60
|
+
agentRationale?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate a wire payload into a typed Ms365WritePreview. Returns null
|
|
65
|
+
* on malformed input (defense in depth — the hook is trusted but the
|
|
66
|
+
* payload may be corrupted in transit / by future-version drift).
|
|
67
|
+
*/
|
|
68
|
+
export function validateMs365Preview(input: unknown): Ms365WritePreview | null {
|
|
69
|
+
if (!input || typeof input !== "object") return null;
|
|
70
|
+
const o = input as Record<string, unknown>;
|
|
71
|
+
if (typeof o.agentName !== "string" || o.agentName.length === 0) return null;
|
|
72
|
+
if (typeof o.toolName !== "string" || o.toolName.length === 0) return null;
|
|
73
|
+
if (typeof o.itemId !== "string" || o.itemId.length === 0) return null;
|
|
74
|
+
if (typeof o.itemDisplayName !== "string") return null;
|
|
75
|
+
if (typeof o.accountEmail !== "string") return null;
|
|
76
|
+
const out: Ms365WritePreview = {
|
|
77
|
+
agentName: o.agentName,
|
|
78
|
+
toolName: o.toolName,
|
|
79
|
+
itemId: o.itemId,
|
|
80
|
+
itemDisplayName: o.itemDisplayName,
|
|
81
|
+
accountEmail: o.accountEmail,
|
|
82
|
+
};
|
|
83
|
+
if (typeof o.deepLink === "string") out.deepLink = o.deepLink;
|
|
84
|
+
if (typeof o.sizeBytesBefore === "number") out.sizeBytesBefore = o.sizeBytesBefore;
|
|
85
|
+
if (typeof o.sizeBytesAfter === "number") out.sizeBytesAfter = o.sizeBytesAfter;
|
|
86
|
+
if (typeof o.agentRationale === "string") out.agentRationale = o.agentRationale;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
91
|
+
// Handler — DI shape mirrors DriveApprovalHandlerDeps
|
|
92
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export interface Ms365ApprovalHandlerDeps {
|
|
95
|
+
agentName: string;
|
|
96
|
+
loadAllowFrom: () => string[];
|
|
97
|
+
loadTargetChat: () => {
|
|
98
|
+
chatId: number | string;
|
|
99
|
+
threadId?: number;
|
|
100
|
+
} | null;
|
|
101
|
+
registerApproval: (args: {
|
|
102
|
+
agent_unit: string;
|
|
103
|
+
scope: string;
|
|
104
|
+
action: string;
|
|
105
|
+
approver_set: string[];
|
|
106
|
+
why: string;
|
|
107
|
+
ttl_ms: number;
|
|
108
|
+
}) => Promise<{ request_id: string; expires_at_ms: number } | null>;
|
|
109
|
+
postCard: (args: {
|
|
110
|
+
chatId: number | string;
|
|
111
|
+
threadId?: number;
|
|
112
|
+
text: string;
|
|
113
|
+
replyMarkup: unknown;
|
|
114
|
+
}) => Promise<{ messageId: number } | null>;
|
|
115
|
+
/**
|
|
116
|
+
* Build the inline keyboard. Defaults to 2-button [✅ Approve]
|
|
117
|
+
* [🚫 Deny] using callback_data `apv:<requestId>:once|deny` — the
|
|
118
|
+
* SAME shape Drive uses. The existing gateway `apv:` handler at
|
|
119
|
+
* `gateway.ts:handleApprovalCallback` consumes the kernel state
|
|
120
|
+
* machine generically (no provider awareness). Reused on purpose:
|
|
121
|
+
* provider-namespaced callback_data would silently drop button
|
|
122
|
+
* taps because there's no `ms365:` branch in the dispatcher.
|
|
123
|
+
* Reviewer of PR 4 caught this — the original `ms365:` prefix was
|
|
124
|
+
* an unwired dead end.
|
|
125
|
+
*/
|
|
126
|
+
buildKeyboard?: (requestId: string) => unknown;
|
|
127
|
+
log?: (msg: string) => void;
|
|
128
|
+
defaultTtlMs?: number;
|
|
129
|
+
maxTtlMs?: number;
|
|
130
|
+
minTtlMs?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
134
|
+
const MAX_TTL_MS = 30 * 60 * 1000;
|
|
135
|
+
const MIN_TTL_MS = 30 * 1000;
|
|
136
|
+
|
|
137
|
+
function defaultKeyboard(requestId: string): unknown {
|
|
138
|
+
// `apv:<requestId>:once` is the kernel-generic approval shape used
|
|
139
|
+
// by Drive's diff-preview cards. The existing `apv:` dispatch
|
|
140
|
+
// branch at gateway.ts:14149 routes the kernel consume + record
|
|
141
|
+
// for any provider. Mirroring `apv:` keeps the M365 cards wired
|
|
142
|
+
// through the same well-tested approval state machine instead of a
|
|
143
|
+
// new provider-namespaced one with its own bug surface.
|
|
144
|
+
return {
|
|
145
|
+
inline_keyboard: [
|
|
146
|
+
[
|
|
147
|
+
{ text: "✅ Approve", callback_data: `apv:${requestId}:once` },
|
|
148
|
+
{ text: "🚫 Deny", callback_data: `apv:${requestId}:deny` },
|
|
149
|
+
],
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build the card body. Plain text (no Markdown) to keep escaping
|
|
156
|
+
* trivial. Truncates the rationale + display name to fit Telegram's
|
|
157
|
+
* 4096-char message limit with safety margin.
|
|
158
|
+
*/
|
|
159
|
+
export function buildMs365CardText(p: Ms365WritePreview): string {
|
|
160
|
+
const lines: string[] = [];
|
|
161
|
+
lines.push(`📄 Microsoft 365 write approval`);
|
|
162
|
+
lines.push("");
|
|
163
|
+
lines.push(`Agent: ${truncate(p.agentName, 64)}`);
|
|
164
|
+
lines.push(`Tool: ${truncate(p.toolName.replace(/^mcp__/, ""), 96)}`);
|
|
165
|
+
lines.push(`Item: ${truncate(p.itemDisplayName, 256)}`);
|
|
166
|
+
if (p.itemId !== "(new)") {
|
|
167
|
+
lines.push(`ID: ${truncate(p.itemId, 96)}`);
|
|
168
|
+
}
|
|
169
|
+
lines.push(`Account: ${truncate(p.accountEmail, 96)}`);
|
|
170
|
+
if (
|
|
171
|
+
typeof p.sizeBytesBefore === "number" ||
|
|
172
|
+
typeof p.sizeBytesAfter === "number"
|
|
173
|
+
) {
|
|
174
|
+
const before = p.sizeBytesBefore ?? 0;
|
|
175
|
+
const after = p.sizeBytesAfter ?? 0;
|
|
176
|
+
const delta = after - before;
|
|
177
|
+
const sign = delta >= 0 ? "+" : "";
|
|
178
|
+
lines.push(`Size: ${humanBytes(before)} → ${humanBytes(after)} (${sign}${humanBytes(delta)})`);
|
|
179
|
+
}
|
|
180
|
+
if (p.deepLink) {
|
|
181
|
+
lines.push(`Link: ${truncate(p.deepLink, 256)}`);
|
|
182
|
+
}
|
|
183
|
+
if (p.agentRationale) {
|
|
184
|
+
lines.push("");
|
|
185
|
+
lines.push(`💬 ${truncate(p.agentRationale, 512)}`);
|
|
186
|
+
}
|
|
187
|
+
lines.push("");
|
|
188
|
+
lines.push(
|
|
189
|
+
"⚠️ Weak attestation (RFC §8 v1): operator should click through to verify the actual change before approving. Structural diff coming v1.5.",
|
|
190
|
+
);
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function truncate(s: string, n: number): string {
|
|
195
|
+
if (s.length <= n) return s;
|
|
196
|
+
return s.slice(0, n - 1) + "…";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function humanBytes(bytes: number): string {
|
|
200
|
+
const abs = Math.abs(bytes);
|
|
201
|
+
if (abs < 1024) return `${bytes}B`;
|
|
202
|
+
if (abs < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
203
|
+
if (abs < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
|
|
204
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)}GB`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function clampTtl(
|
|
208
|
+
requested: number | undefined,
|
|
209
|
+
def: number,
|
|
210
|
+
min: number,
|
|
211
|
+
max: number,
|
|
212
|
+
): number {
|
|
213
|
+
if (typeof requested !== "number" || !Number.isFinite(requested)) return def;
|
|
214
|
+
return Math.max(min, Math.min(max, requested));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Main handler — wire the inbound IPC message to kernel + card post.
|
|
219
|
+
*/
|
|
220
|
+
export async function handleRequestMs365Approval(
|
|
221
|
+
client: IpcClient,
|
|
222
|
+
msg: RequestMs365ApprovalMessage,
|
|
223
|
+
deps: Ms365ApprovalHandlerDeps,
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
const log = deps.log ?? (() => {});
|
|
226
|
+
const sendResponse = (
|
|
227
|
+
ok: boolean,
|
|
228
|
+
extra: {
|
|
229
|
+
requestId?: string;
|
|
230
|
+
expiresAtMs?: number;
|
|
231
|
+
reason?: string;
|
|
232
|
+
} = {},
|
|
233
|
+
): void => {
|
|
234
|
+
client.send({
|
|
235
|
+
type: "ms365_approval_posted",
|
|
236
|
+
correlationId: msg.correlationId,
|
|
237
|
+
ok,
|
|
238
|
+
...extra,
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Cross-agent guard
|
|
243
|
+
if (msg.agentName !== deps.agentName) {
|
|
244
|
+
log(
|
|
245
|
+
`ms365-approval: cross-agent request rejected (msg=${msg.agentName} vs gateway=${deps.agentName})`,
|
|
246
|
+
);
|
|
247
|
+
sendResponse(false, { reason: "cross-agent request rejected" });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const preview = validateMs365Preview(msg.preview);
|
|
252
|
+
if (!preview) {
|
|
253
|
+
log("ms365-approval: invalid preview payload");
|
|
254
|
+
sendResponse(false, { reason: "invalid preview payload" });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const allowFrom = deps.loadAllowFrom();
|
|
259
|
+
if (allowFrom.length === 0) {
|
|
260
|
+
log("ms365-approval: no operator allowFrom configured");
|
|
261
|
+
sendResponse(false, { reason: "no operator allowFrom configured" });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const targetChat = deps.loadTargetChat();
|
|
266
|
+
if (!targetChat) {
|
|
267
|
+
log("ms365-approval: no target chat resolved");
|
|
268
|
+
sendResponse(false, { reason: "no target chat resolved" });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const ttlMs = clampTtl(
|
|
273
|
+
msg.ttlMs,
|
|
274
|
+
deps.defaultTtlMs ?? DEFAULT_TTL_MS,
|
|
275
|
+
deps.minTtlMs ?? MIN_TTL_MS,
|
|
276
|
+
deps.maxTtlMs ?? MAX_TTL_MS,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const scope = `ms-365:write:${preview.itemId}`;
|
|
280
|
+
const why =
|
|
281
|
+
preview.agentRationale ??
|
|
282
|
+
`${preview.toolName} on ${preview.itemDisplayName}`;
|
|
283
|
+
|
|
284
|
+
let registered;
|
|
285
|
+
try {
|
|
286
|
+
registered = await deps.registerApproval({
|
|
287
|
+
agent_unit: preview.agentName,
|
|
288
|
+
scope,
|
|
289
|
+
action: "write",
|
|
290
|
+
approver_set: allowFrom,
|
|
291
|
+
why,
|
|
292
|
+
ttl_ms: ttlMs,
|
|
293
|
+
});
|
|
294
|
+
} catch (err) {
|
|
295
|
+
const msg2 = err instanceof Error ? err.message : String(err);
|
|
296
|
+
log(`ms365-approval: kernel register failed — ${msg2}`);
|
|
297
|
+
sendResponse(false, { reason: `kernel register failed: ${msg2}` });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!registered) {
|
|
301
|
+
sendResponse(false, { reason: "kernel returned no request_id" });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const text = buildMs365CardText(preview);
|
|
306
|
+
const replyMarkup = (deps.buildKeyboard ?? defaultKeyboard)(registered.request_id);
|
|
307
|
+
|
|
308
|
+
let posted;
|
|
309
|
+
try {
|
|
310
|
+
posted = await deps.postCard({
|
|
311
|
+
chatId: targetChat.chatId,
|
|
312
|
+
threadId: targetChat.threadId,
|
|
313
|
+
text,
|
|
314
|
+
replyMarkup,
|
|
315
|
+
});
|
|
316
|
+
} catch (err) {
|
|
317
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
318
|
+
log(`ms365-approval: card post threw — ${m}`);
|
|
319
|
+
sendResponse(false, { reason: `card post failed: ${m}` });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (!posted) {
|
|
323
|
+
sendResponse(false, { reason: "card post returned null" });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
log(
|
|
328
|
+
`ms365-approval: posted card msg=${posted.messageId} request=${registered.request_id} expires=${new Date(registered.expires_at_ms).toISOString()}`,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
sendResponse(true, {
|
|
332
|
+
requestId: registered.request_id,
|
|
333
|
+
expiresAtMs: registered.expires_at_ms,
|
|
334
|
+
});
|
|
335
|
+
}
|