gogcli-mcp 4.4.0 → 4.5.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +827 -315
- package/dist/lib.js +832 -281
- package/manifest.json +7 -7
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +102 -115
- package/src/gmail-dispatch-guard.ts +28 -21
- package/src/lib.ts +6 -2
- package/src/send-confirm-token.ts +17 -155
- package/src/tools/api.ts +74 -8
- package/src/tools/appscript.ts +63 -2
- package/src/tools/calendar.ts +83 -13
- package/src/tools/chat.ts +35 -4
- package/src/tools/classroom.ts +254 -25
- package/src/tools/docs.ts +7 -1
- package/src/tools/drive.ts +82 -10
- package/src/tools/gmail.ts +37 -1
- package/src/tools/sheets.ts +16 -1
- package/src/tools/utils.ts +10 -4
- package/tests/gmail-dispatch-guard.test.ts +60 -25
- package/tests/send-confirm-token.test.ts +17 -197
- package/tests/tools/api.test.ts +130 -5
- package/tests/tools/appscript.test.ts +6 -2
- package/tests/tools/dispatch-gates-more.test.ts +447 -0
- package/tests/tools/dispatch-gates.test.ts +74 -13
- package/tests/tools/docs.test.ts +5 -6
- package/tests/tools/drive.test.ts +2 -5
- package/tests/tools/gmail-confirm-token.test.ts +13 -11
- package/tests/tools/run-vets-more.test.ts +269 -0
- package/tests/tools/run-vets.test.ts +158 -4
- package/tests/tools/sheets.test.ts +10 -11
- package/tests/tools/utils.test.ts +38 -41
|
@@ -94,7 +94,7 @@ describe('logGmailDispatch', () => {
|
|
|
94
94
|
logGmailDispatch('gog_gmail_reply', ['me@example.com', 'outside@other.com'], 'me@example.com');
|
|
95
95
|
const event = loggedEvent();
|
|
96
96
|
expect(event.hasExternalRecipients).toBe(true);
|
|
97
|
-
expect(event.
|
|
97
|
+
expect(event.externalDomains).toEqual(['other.com']);
|
|
98
98
|
expect(event.externalRecipientCount).toBe(1);
|
|
99
99
|
});
|
|
100
100
|
|
|
@@ -102,34 +102,34 @@ describe('logGmailDispatch', () => {
|
|
|
102
102
|
process.env.GOG_ACCOUNT = 'me@example.com';
|
|
103
103
|
logGmailDispatch('gog_gmail_send', ['me@example.com', 'outside@other.com']);
|
|
104
104
|
const event = loggedEvent();
|
|
105
|
-
expect(event.
|
|
105
|
+
expect(event.externalDomains).toEqual(['other.com']);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
it('adds every domain in GOG_GMAIL_TRUSTED_DOMAINS on top of the account domain', () => {
|
|
109
109
|
process.env.GOG_GMAIL_TRUSTED_DOMAINS = 'law-firm.example, partner.example';
|
|
110
110
|
logGmailDispatch('gog_gmail_reply_all', ['counsel@law-firm.example', 'stranger@random.example'], 'me@example.com');
|
|
111
111
|
const event = loggedEvent();
|
|
112
|
-
expect(event.
|
|
112
|
+
expect(event.externalDomains).toEqual(['random.example']);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
it('reports no external recipients when every address is trusted', () => {
|
|
116
116
|
logGmailDispatch('gog_gmail_forward', ['me@example.com'], 'me@example.com');
|
|
117
117
|
const event = loggedEvent();
|
|
118
118
|
expect(event.hasExternalRecipients).toBe(false);
|
|
119
|
-
expect(event.
|
|
119
|
+
expect(event.externalDomains).toEqual([]);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
it('treats an account with no @ as contributing no trusted domain', () => {
|
|
123
123
|
logGmailDispatch('gog_gmail_send', ['a@example.com'], 'not-an-email');
|
|
124
124
|
const event = loggedEvent();
|
|
125
|
-
expect(event.
|
|
125
|
+
expect(event.externalDomains).toEqual(['example.com']);
|
|
126
126
|
});
|
|
127
127
|
|
|
128
128
|
it('ignores an empty segment in GOG_GMAIL_TRUSTED_DOMAINS (e.g. a trailing comma)', () => {
|
|
129
129
|
process.env.GOG_GMAIL_TRUSTED_DOMAINS = 'law-firm.example,,';
|
|
130
130
|
logGmailDispatch('gog_gmail_send', ['counsel@law-firm.example'], 'me@example.com');
|
|
131
131
|
const event = loggedEvent();
|
|
132
|
-
expect(event.
|
|
132
|
+
expect(event.externalDomains).toEqual([]);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
it('treats unresolved desktop placeholders as unset env vars', () => {
|
|
@@ -137,13 +137,29 @@ describe('logGmailDispatch', () => {
|
|
|
137
137
|
process.env.GOG_ACCOUNT = '${user_config.account}';
|
|
138
138
|
logGmailDispatch('gog_gmail_send', ['person@user_config.trusted_domains']);
|
|
139
139
|
const event = loggedEvent();
|
|
140
|
-
expect(event.
|
|
140
|
+
expect(event.externalDomains).toEqual(['user_config.trusted_domains']);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
it('treats a recipient with no @ as external, not a crash', () => {
|
|
144
144
|
logGmailDispatch('gog_gmail_send', ['not-an-email'], 'me@example.com');
|
|
145
145
|
const event = loggedEvent();
|
|
146
|
-
expect(event.
|
|
146
|
+
expect(event.externalRecipientCount).toBe(1);
|
|
147
|
+
expect(event.externalDomains).toEqual(['(no domain)']);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// PRIV-1 (fleet-audit #1012): on mcp-host this line is host log data the user
|
|
151
|
+
// never sees, so it names domains, never addresses — the count and the
|
|
152
|
+
// domain are what the audit purpose needs (an unexpected law firm, a
|
|
153
|
+
// wrong-number alias), the local part is a third party's identity.
|
|
154
|
+
it('never writes a recipient address to stderr', () => {
|
|
155
|
+
logGmailDispatch('gog_gmail_reply_all', ['me@example.com', 'counsel@law-firm.example', 'second@law-firm.example', 'Kid@School.example'], 'me@example.com');
|
|
156
|
+
const line = writeSpy.mock.calls[0][0] as string;
|
|
157
|
+
expect(line).not.toMatch(/@/);
|
|
158
|
+
expect(line).not.toContain('externalRecipients');
|
|
159
|
+
const event = loggedEvent();
|
|
160
|
+
expect(event.recipientCount).toBe(4);
|
|
161
|
+
expect(event.externalRecipientCount).toBe(3);
|
|
162
|
+
expect(event.externalDomains).toEqual(['law-firm.example', 'school.example']);
|
|
147
163
|
});
|
|
148
164
|
});
|
|
149
165
|
|
|
@@ -298,7 +314,7 @@ describe('attachmentDetails', () => {
|
|
|
298
314
|
});
|
|
299
315
|
|
|
300
316
|
// ============================================================================
|
|
301
|
-
// THE TOKEN FALLBACK (
|
|
317
|
+
// THE TOKEN FALLBACK (MCP_CONFIRM_MODE, default ask-user). Elicitation stays the
|
|
302
318
|
// primary rail; this only replaces the REFUSAL a client that cannot be prompted
|
|
303
319
|
// would otherwise get.
|
|
304
320
|
// ============================================================================
|
|
@@ -308,9 +324,9 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
|
|
|
308
324
|
|
|
309
325
|
beforeEach(() => {
|
|
310
326
|
process.env = { ...ORIGINAL_ENV };
|
|
311
|
-
delete process.env.
|
|
312
|
-
delete process.env.
|
|
313
|
-
delete process.env.
|
|
327
|
+
delete process.env.MCP_CONFIRM_MODE;
|
|
328
|
+
delete process.env.MCP_CONFIRM_TTL_SECONDS;
|
|
329
|
+
delete process.env.MCP_CONFIRM_SECRET;
|
|
314
330
|
process.env.GOG_ACCOUNT = 'me@example.com';
|
|
315
331
|
resetConfirmTokenState();
|
|
316
332
|
});
|
|
@@ -341,37 +357,56 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
|
|
|
341
357
|
return parse(r);
|
|
342
358
|
}
|
|
343
359
|
|
|
344
|
-
it('with
|
|
360
|
+
it('with MCP_CONFIRM_MODE=refuse, keeps the refusal and names the switch', async () => {
|
|
361
|
+
|
|
362
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
345
363
|
const fb = fallback();
|
|
346
364
|
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fb));
|
|
347
365
|
expect(r.reason).toBe('confirmation-unsupported');
|
|
348
|
-
expect(r.note).toContain('
|
|
366
|
+
expect(r.note).toContain('Set MCP_CONFIRM_MODE=ask-user');
|
|
349
367
|
expect(r.note).toContain('still saved');
|
|
350
368
|
expect(fb.subject).not.toHaveBeenCalled();
|
|
351
369
|
});
|
|
352
370
|
|
|
353
371
|
it('names the switch even for an op with no other note (autoreply)', async () => {
|
|
372
|
+
|
|
373
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
354
374
|
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.autoreply', {}, fallback()));
|
|
355
|
-
expect(r.note).toContain('
|
|
375
|
+
expect(r.note).toContain('MCP_CONFIRM_MODE=ask-user');
|
|
356
376
|
});
|
|
357
377
|
|
|
358
378
|
it('never offers the switch to a caller that passed no fallback (a forwarding filter)', async () => {
|
|
359
|
-
process.env.
|
|
379
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
360
380
|
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.filter-forward', {}));
|
|
361
381
|
expect(r.reason).toBe('confirmation-unsupported');
|
|
362
|
-
expect(r.note).not.toContain('
|
|
382
|
+
expect(r.note).not.toContain('MCP_CONFIRM_MODE');
|
|
363
383
|
});
|
|
364
384
|
|
|
365
385
|
it('leaves elicitation untouched even with the env set and a token passed', async () => {
|
|
366
|
-
process.env.
|
|
367
|
-
const fb = fallback('
|
|
386
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
387
|
+
const fb = fallback('not-a-real-token');
|
|
368
388
|
expect(await requireGmailDispatchConfirmation(CAN_BE_ASKED, 'gmail.drafts-send', {}, fb))
|
|
369
389
|
.toMatchObject({ resultType: 'input_required' });
|
|
370
390
|
expect(fb.subject).not.toHaveBeenCalled();
|
|
371
391
|
});
|
|
372
392
|
|
|
373
|
-
|
|
374
|
-
|
|
393
|
+
// The fleet default: with MCP_CONFIRM_MODE unset, a client that cannot be
|
|
394
|
+
// prompted gets the two-step flow rather than a refusal.
|
|
395
|
+
it('with MCP_CONFIRM_MODE unset, runs the two-step flow (ask-user is the default)', async () => {
|
|
396
|
+
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback()));
|
|
397
|
+
expect(r).toMatchObject({ status: 'confirmation-required', instruction: CONFIRM_INSTRUCTION });
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('with MCP_CONFIRM_MODE=auto, tells the model it may proceed after reviewing the preview', async () => {
|
|
401
|
+
process.env.MCP_CONFIRM_MODE = 'auto';
|
|
402
|
+
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback()));
|
|
403
|
+
expect(r.status).toBe('confirmation-required');
|
|
404
|
+
expect(r.instruction).toMatch(/MCP_CONFIRM_MODE=auto/);
|
|
405
|
+
expect(r.instruction).not.toBe(CONFIRM_INSTRUCTION);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe('with MCP_CONFIRM_MODE=ask-user', () => {
|
|
409
|
+
beforeEach(() => { process.env.MCP_CONFIRM_MODE = 'ask-user'; });
|
|
375
410
|
|
|
376
411
|
it('phase 1 returns the full preview, a token and the instruction, and does not proceed', async () => {
|
|
377
412
|
const r = await phaseOne();
|
|
@@ -385,7 +420,7 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
|
|
|
385
420
|
instruction: CONFIRM_INSTRUCTION,
|
|
386
421
|
});
|
|
387
422
|
expect(r.instruction).toBe('Show this preview to the user verbatim and send only after they explicitly approve in chat. Then call again with confirmToken.');
|
|
388
|
-
expect(r.confirmToken).toMatch(/^
|
|
423
|
+
expect(r.confirmToken).toMatch(/^mcpu\.token\.v1\./);
|
|
389
424
|
expect(typeof r.expiresAt).toBe('string');
|
|
390
425
|
});
|
|
391
426
|
|
|
@@ -400,8 +435,8 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
|
|
|
400
435
|
const r = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken, rotated));
|
|
401
436
|
expect((r as CallToolResult).isError).toBe(true);
|
|
402
437
|
const body = parse(r);
|
|
403
|
-
expect(body).toMatchObject({ status: 'confirmation-rejected', error: 'DRAFT_CHANGED', reason: '
|
|
404
|
-
expect(body.note).toMatch(/
|
|
438
|
+
expect(body).toMatchObject({ status: 'confirmation-rejected', error: 'DRAFT_CHANGED', reason: 'revision-changed', dispatched: false, instruction: CONFIRM_INSTRUCTION });
|
|
439
|
+
expect(body.note).toMatch(/edited since/);
|
|
405
440
|
expect(body.confirmToken).not.toBe(confirmToken);
|
|
406
441
|
expect(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(body.confirmToken, rotated))).toBeUndefined();
|
|
407
442
|
});
|
|
@@ -422,7 +457,7 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
|
|
|
422
457
|
});
|
|
423
458
|
|
|
424
459
|
it('TOKEN_EXPIRED after the TTL', async () => {
|
|
425
|
-
process.env.
|
|
460
|
+
process.env.MCP_CONFIRM_TTL_SECONDS = '60';
|
|
426
461
|
vi.useFakeTimers();
|
|
427
462
|
vi.setSystemTime(new Date('2026-09-24T10:00:00Z'));
|
|
428
463
|
const { confirmToken } = await phaseOne();
|
|
@@ -1,200 +1,20 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
account: 'me@example.com',
|
|
18
|
-
target: 'r123',
|
|
19
|
-
revision: 'msg-1',
|
|
20
|
-
payloadHash: hashSendPayload({ to: 'a@example.com', body: 'hello' }),
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const NOW = 1_800_000_000_000;
|
|
24
|
-
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
process.env = { ...ORIGINAL_ENV };
|
|
27
|
-
delete process.env.GOG_SEND_CONFIRM_FALLBACK;
|
|
28
|
-
delete process.env.GOG_CONFIRM_SECRET;
|
|
29
|
-
delete process.env.GOG_CONFIRM_TTL_SECONDS;
|
|
30
|
-
resetConfirmTokenState();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
afterEach(() => {
|
|
34
|
-
process.env = ORIGINAL_ENV;
|
|
35
|
-
resetConfirmTokenState();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe('hashSendPayload', () => {
|
|
39
|
-
it('is independent of key order, so a re-read payload hashes the same', () => {
|
|
40
|
-
expect(hashSendPayload({ a: 1, b: { c: 2, d: [1, 2] } }))
|
|
41
|
-
.toBe(hashSendPayload({ b: { d: [1, 2], c: 2 }, a: 1 }));
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('changes when any value changes, including array order', () => {
|
|
45
|
-
const base = hashSendPayload({ to: ['a', 'b'], body: 'x' });
|
|
46
|
-
expect(hashSendPayload({ to: ['b', 'a'], body: 'x' })).not.toBe(base);
|
|
47
|
-
expect(hashSendPayload({ to: ['a', 'b'], body: 'x ' })).not.toBe(base);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('treats an undefined field as absent', () => {
|
|
51
|
-
expect(hashSendPayload({ a: 1, b: undefined })).toBe(hashSendPayload({ a: 1 }));
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('distinguishes null from absent', () => {
|
|
55
|
-
expect(hashSendPayload({ a: null })).not.toBe(hashSendPayload({}));
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe('sendConfirmFallbackEnabled', () => {
|
|
60
|
-
it('is off unless GOG_SEND_CONFIRM_FALLBACK is exactly "token" (case-insensitive)', () => {
|
|
61
|
-
expect(sendConfirmFallbackEnabled()).toBe(false);
|
|
62
|
-
process.env.GOG_SEND_CONFIRM_FALLBACK = '1';
|
|
63
|
-
expect(sendConfirmFallbackEnabled()).toBe(false);
|
|
64
|
-
process.env.GOG_SEND_CONFIRM_FALLBACK = ' Token ';
|
|
65
|
-
expect(sendConfirmFallbackEnabled()).toBe(true);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('treats an unresolved .mcpb placeholder as unset', () => {
|
|
69
|
-
process.env.GOG_SEND_CONFIRM_FALLBACK = '${user_config.send_confirm_fallback}';
|
|
70
|
-
expect(sendConfirmFallbackEnabled()).toBe(false);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe('confirmTokenTtlSeconds', () => {
|
|
75
|
-
it('defaults to ten minutes', () => {
|
|
76
|
-
expect(CONFIRM_TOKEN_TTL_DEFAULT_SECONDS).toBe(600);
|
|
77
|
-
expect(confirmTokenTtlSeconds()).toBe(600);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('honours a positive integer GOG_CONFIRM_TTL_SECONDS', () => {
|
|
81
|
-
process.env.GOG_CONFIRM_TTL_SECONDS = '30';
|
|
82
|
-
expect(confirmTokenTtlSeconds()).toBe(30);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it.each(['0', '-5', 'abc', '1.5', ''])('falls back to the default for %j', (value) => {
|
|
86
|
-
process.env.GOG_CONFIRM_TTL_SECONDS = value;
|
|
87
|
-
expect(confirmTokenTtlSeconds()).toBe(600);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
describe('issueConfirmToken / verifyConfirmToken', () => {
|
|
92
|
-
it('accepts a fresh token for the exact binding it was issued for', () => {
|
|
93
|
-
const { token, expiresAt } = issueConfirmToken(BINDING, NOW);
|
|
94
|
-
expect(expiresAt).toBe(new Date(NOW + 600_000).toISOString());
|
|
95
|
-
expect(verifyConfirmToken(token, BINDING, NOW + 1_000)).toEqual({ ok: true });
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('is single-use: the second presentation is TOKEN_REUSED', () => {
|
|
99
|
-
const { token } = issueConfirmToken(BINDING, NOW);
|
|
100
|
-
expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
|
|
101
|
-
expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_REUSED' });
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('expires after the TTL', () => {
|
|
105
|
-
const { token } = issueConfirmToken(BINDING, NOW);
|
|
106
|
-
expect(verifyConfirmToken(token, BINDING, NOW + 600_001)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('uses GOG_CONFIRM_TTL_SECONDS at issue time', () => {
|
|
110
|
-
process.env.GOG_CONFIRM_TTL_SECONDS = '5';
|
|
111
|
-
const { token } = issueConfirmToken(BINDING, NOW);
|
|
112
|
-
expect(verifyConfirmToken(token, BINDING, NOW + 5_001)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('does not consume a token that failed verification', () => {
|
|
116
|
-
const { token } = issueConfirmToken(BINDING, NOW);
|
|
117
|
-
const changed = { ...BINDING, payloadHash: hashSendPayload({ to: 'b@example.com' }) };
|
|
118
|
-
expect(verifyConfirmToken(token, changed, NOW)).toMatchObject({ ok: false, error: 'DRAFT_CHANGED' });
|
|
119
|
-
expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('reports DRAFT_CHANGED with reason payload-changed when the payload hash differs', () => {
|
|
123
|
-
const { token } = issueConfirmToken(BINDING, NOW);
|
|
124
|
-
const changed = { ...BINDING, payloadHash: hashSendPayload({ to: 'a@example.com', body: 'HELLO' }) };
|
|
125
|
-
expect(verifyConfirmToken(token, changed, NOW)).toEqual({ ok: false, error: 'DRAFT_CHANGED', reason: 'payload-changed' });
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('reports DRAFT_CHANGED with reason message-id-rotated when the revision rotated', () => {
|
|
129
|
-
const { token } = issueConfirmToken(BINDING, NOW);
|
|
130
|
-
expect(verifyConfirmToken(token, { ...BINDING, revision: 'msg-2' }, NOW))
|
|
131
|
-
.toEqual({ ok: false, error: 'DRAFT_CHANGED', reason: 'message-id-rotated' });
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it.each([
|
|
135
|
-
['tool', { tool: 'gog_gmail_send' }],
|
|
136
|
-
['account', { account: 'other@example.com' }],
|
|
137
|
-
['draft/message', { target: 'r999' }],
|
|
138
|
-
] as const)('never accepts a token issued for a different %s', (_label, override) => {
|
|
139
|
-
const { token } = issueConfirmToken(BINDING, NOW);
|
|
140
|
-
expect(verifyConfirmToken(token, { ...BINDING, ...override }, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('rejects a tampered claim as TOKEN_INVALID', () => {
|
|
144
|
-
const { token } = issueConfirmToken(BINDING, NOW);
|
|
145
|
-
const [prefix, claims, sig] = token.split('.');
|
|
146
|
-
const decoded = JSON.parse(Buffer.from(claims!, 'base64url').toString('utf8'));
|
|
147
|
-
decoded.exp += 3_600_000;
|
|
148
|
-
const forged = `${prefix}.${Buffer.from(JSON.stringify(decoded)).toString('base64url')}.${sig}`;
|
|
149
|
-
expect(verifyConfirmToken(forged, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('rejects a tampered signature as TOKEN_INVALID', () => {
|
|
153
|
-
const { token } = issueConfirmToken(BINDING, NOW);
|
|
154
|
-
const flipped = token.slice(0, -2) + (token.endsWith('AA') ? 'BB' : 'AA');
|
|
155
|
-
expect(verifyConfirmToken(flipped, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it.each(['', 'garbage', 'gct1.a.b', 'x.y.z', 'gct1..', 'gct1.a.b.c'])('rejects malformed token %j as TOKEN_INVALID', (token) => {
|
|
159
|
-
expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('rejects a correctly signed token whose claims are not JSON', async () => {
|
|
163
|
-
const { createHmac } = await import('node:crypto');
|
|
164
|
-
process.env.GOG_CONFIRM_SECRET = 'a-shared-secret';
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { confirmKeyFromEnv, issueConfirmToken, verifyConfirmToken } from '@chrischall/mcp-utils';
|
|
3
|
+
import { confirmSpentStore, resetConfirmTokenState } from '../src/send-confirm-token.js';
|
|
4
|
+
|
|
5
|
+
// The token mechanism and the MCP_CONFIRM_* env layer live in
|
|
6
|
+
// @chrischall/mcp-utils and are tested there. What stays here is this server's
|
|
7
|
+
// spent-token store and the seam its tests reset it with.
|
|
8
|
+
describe('confirmSpentStore', () => {
|
|
9
|
+
it('is one store for the process, emptied by resetConfirmTokenState', () => {
|
|
10
|
+
const binding = { tool: 'gog_gmail_send', account: 'me@example.com', target: '', payloadHash: 'h' };
|
|
11
|
+
const key = confirmKeyFromEnv({});
|
|
12
|
+
const { token } = issueConfirmToken(key, binding);
|
|
13
|
+
expect(verifyConfirmToken(key, token, binding, { spent: confirmSpentStore() })).toEqual({ ok: true });
|
|
14
|
+
expect(confirmSpentStore()).toBe(confirmSpentStore());
|
|
15
|
+
expect(confirmSpentStore().size).toBe(1);
|
|
16
|
+
expect(verifyConfirmToken(key, token, binding, { spent: confirmSpentStore() })).toEqual({ ok: false, error: 'TOKEN_REUSED' });
|
|
165
17
|
resetConfirmTokenState();
|
|
166
|
-
|
|
167
|
-
const sig = createHmac('sha256', 'a-shared-secret').update(`gct1.${claims}`).digest('base64url');
|
|
168
|
-
expect(verifyConfirmToken(`gct1.${claims}.${sig}`, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('rejects a token signed by a previous process (per-process secret)', () => {
|
|
172
|
-
const { token } = issueConfirmToken(BINDING, NOW);
|
|
173
|
-
resetConfirmTokenState();
|
|
174
|
-
expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('with GOG_CONFIRM_SECRET set, a token survives a restart', () => {
|
|
178
|
-
process.env.GOG_CONFIRM_SECRET = 'a-shared-secret';
|
|
179
|
-
resetConfirmTokenState();
|
|
180
|
-
const { token } = issueConfirmToken(BINDING, NOW);
|
|
181
|
-
resetConfirmTokenState();
|
|
182
|
-
expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('binds a revision-less subject too', () => {
|
|
186
|
-
const noRevision = { ...BINDING, revision: undefined };
|
|
187
|
-
const { token } = issueConfirmToken(noRevision, NOW);
|
|
188
|
-
expect(verifyConfirmToken(token, noRevision, NOW)).toEqual({ ok: true });
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('forgets used tokens once they would have expired anyway', () => {
|
|
192
|
-
const first = issueConfirmToken(BINDING, NOW);
|
|
193
|
-
expect(verifyConfirmToken(first.token, BINDING, NOW)).toEqual({ ok: true });
|
|
194
|
-
// A later verification prunes the spent entry; the old token is then
|
|
195
|
-
// rejected as expired rather than remembered forever.
|
|
196
|
-
const second = issueConfirmToken(BINDING, NOW + 700_000);
|
|
197
|
-
expect(verifyConfirmToken(second.token, BINDING, NOW + 700_000)).toEqual({ ok: true });
|
|
198
|
-
expect(verifyConfirmToken(first.token, BINDING, NOW + 700_000)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
|
|
18
|
+
expect(confirmSpentStore().size).toBe(0);
|
|
199
19
|
});
|
|
200
20
|
});
|
package/tests/tools/api.test.ts
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
|
|
2
3
|
import { registerApiTools } from '../../src/tools/api.js';
|
|
3
4
|
import * as runner from '../../src/runner.js';
|
|
4
5
|
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
6
|
import { pos } from '../../src/argv.js';
|
|
7
|
+
import { resetConfirmTokenState } from '../../src/send-confirm-token.js';
|
|
8
|
+
import { CONFIRM_ACTION_INSTRUCTION } from '../../src/dispatch-confirmation.js';
|
|
6
9
|
|
|
7
10
|
vi.mock('../../src/runner.js');
|
|
8
11
|
|
|
9
12
|
const setupHandlers = () => createTestHarness(registerApiTools);
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
/** A client that CAN be prompted, answering with `answer` and recording the prompt. */
|
|
15
|
+
async function prompted(answer: ElicitResult = { action: 'accept', content: { confirmed: true } }) {
|
|
16
|
+
const seen: ElicitRequest[] = [];
|
|
17
|
+
const harness = await createTestHarness(registerApiTools, { elicitation: async (r) => { seen.push(r); return answer; } });
|
|
18
|
+
const details = () => JSON.parse(seen[0]!.params.message.split('\n').slice(1).join('\n')).details;
|
|
19
|
+
return { harness, seen, details };
|
|
20
|
+
}
|
|
21
|
+
const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
|
|
22
|
+
|
|
23
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
process.env = { ...ORIGINAL_ENV };
|
|
28
|
+
delete process.env.MCP_CONFIRM_MODE;
|
|
29
|
+
delete process.env.MCP_CONFIRM_TTL_SECONDS;
|
|
30
|
+
delete process.env.MCP_CONFIRM_SECRET;
|
|
31
|
+
resetConfirmTokenState();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => { process.env = ORIGINAL_ENV; });
|
|
12
35
|
|
|
13
36
|
describe('gog_api_list', () => {
|
|
14
37
|
it('lists the default API set', async () => {
|
|
@@ -76,7 +99,7 @@ describe('gog_api_call', () => {
|
|
|
76
99
|
await harness.callTool('gog_api_call', {
|
|
77
100
|
api: 'drive', version: 'v3', method: 'files.create',
|
|
78
101
|
params: '{"fields":"id"}', body: '{"name":"f"}', scope: 'https://www.googleapis.com/auth/drive',
|
|
79
|
-
allowWrite: true, dryRun: true, account: 'a@
|
|
102
|
+
allowWrite: true, dryRun: true, account: 'a@example.com',
|
|
80
103
|
});
|
|
81
104
|
expect(runner.run).toHaveBeenCalledWith(
|
|
82
105
|
[
|
|
@@ -84,13 +107,13 @@ describe('gog_api_call', () => {
|
|
|
84
107
|
'--params={"fields":"id"}', '--body={"name":"f"}',
|
|
85
108
|
'--scope=https://www.googleapis.com/auth/drive', '--allow-write', '--dry-run', '--force',
|
|
86
109
|
],
|
|
87
|
-
{ account: 'a@
|
|
110
|
+
{ account: 'a@example.com', gmailNoSend: true },
|
|
88
111
|
);
|
|
89
112
|
});
|
|
90
113
|
|
|
91
|
-
it('appends --force last even when dry-run is absent', async () => {
|
|
114
|
+
it('appends --force last even when dry-run is absent, once the user has confirmed', async () => {
|
|
92
115
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
93
|
-
const harness = await
|
|
116
|
+
const { harness } = await prompted();
|
|
94
117
|
await harness.callTool('gog_api_call', {
|
|
95
118
|
api: 'drive', version: 'v3', method: 'files.create', allowWrite: true,
|
|
96
119
|
});
|
|
@@ -99,6 +122,108 @@ describe('gog_api_call', () => {
|
|
|
99
122
|
expect(passedArgs[passedArgs.length - 1]).toBe('--force');
|
|
100
123
|
});
|
|
101
124
|
|
|
125
|
+
// SEC-2 (fleet-audit #931): allowWrite is a boolean the MODEL sets, and
|
|
126
|
+
// --force skips gog's own confirmation, so a method blocklist could never be
|
|
127
|
+
// complete (events.move/delete, acl.insert, sendAs.create, updateVacation,
|
|
128
|
+
// batchDelete, ...). Every write now asks the user about the exact
|
|
129
|
+
// api/method/params/body before anything is sent.
|
|
130
|
+
describe('the write gate', () => {
|
|
131
|
+
const write = {
|
|
132
|
+
api: 'calendar', version: 'v3', method: 'events.delete',
|
|
133
|
+
params: '{"calendarId":"primary","eventId":"e1","sendUpdates":"all"}',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
it('prompts with the api, version, method, params and body, then runs once accepted', async () => {
|
|
137
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
138
|
+
const { harness, details, seen } = await prompted();
|
|
139
|
+
await harness.callTool('gog_api_call', { ...write, body: '{"a":1}', scope: 'https://www.googleapis.com/auth/calendar', allowWrite: true, account: 'a@example.com' });
|
|
140
|
+
expect(seen[0]!.params.message).toMatch(/calendar v3 events\.delete/);
|
|
141
|
+
expect(details()).toEqual({
|
|
142
|
+
api: 'calendar', version: 'v3', method: 'events.delete',
|
|
143
|
+
params: { calendarId: 'primary', eventId: 'e1', sendUpdates: 'all' },
|
|
144
|
+
body: { a: 1 },
|
|
145
|
+
scope: 'https://www.googleapis.com/auth/calendar',
|
|
146
|
+
});
|
|
147
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
148
|
+
['api', 'call', pos('calendar'), pos('v3'), pos('events.delete'), `--params=${write.params}`, '--body={"a":1}',
|
|
149
|
+
'--scope=https://www.googleapis.com/auth/calendar', '--allow-write', '--force'],
|
|
150
|
+
{ account: 'a@example.com', gmailNoSend: true },
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('shows a body that is not JSON (an @file reference) as the string it is', async () => {
|
|
155
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
156
|
+
const { harness, details } = await prompted({ action: 'decline' });
|
|
157
|
+
await harness.callTool('gog_api_call', { api: 'drive', version: 'v3', method: 'files.create', body: '@/body.json', params: 'not json', allowWrite: true });
|
|
158
|
+
expect(details()).toEqual({ api: 'drive', version: 'v3', method: 'files.create', params: 'not json', body: '@/body.json' });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('bounds an oversized body in the prompt, marking the cut', async () => {
|
|
162
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
163
|
+
const { harness, details } = await prompted({ action: 'decline' });
|
|
164
|
+
const body = JSON.stringify({ text: 'x'.repeat(3000) });
|
|
165
|
+
await harness.callTool('gog_api_call', { api: 'docs', version: 'v1', method: 'documents.batchUpdate', body, allowWrite: true });
|
|
166
|
+
const shown = details().body as string;
|
|
167
|
+
expect(shown.startsWith(body.slice(0, 2048))).toBe(true);
|
|
168
|
+
expect(shown).toMatch(/more characters not shown\]$/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('runs nothing when the user declines', async () => {
|
|
172
|
+
const { harness } = await prompted({ action: 'decline' });
|
|
173
|
+
const r = json(await harness.callTool('gog_api_call', { ...write, allowWrite: true }));
|
|
174
|
+
expect(r).toMatchObject({ confirmed: false, cancelled: true, action: 'api.call' });
|
|
175
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it.each([
|
|
179
|
+
['a read', { api: 'calendar', version: 'v3', method: 'events.list' }],
|
|
180
|
+
['a dry run', { ...write, allowWrite: true, dryRun: true }],
|
|
181
|
+
['a write without allowWrite (gog refuses it itself)', { ...write }],
|
|
182
|
+
])('does not ask for %s', async (_label, args) => {
|
|
183
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
184
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
185
|
+
const harness = await setupHandlers();
|
|
186
|
+
const r = await harness.callTool('gog_api_call', args);
|
|
187
|
+
expect(r.isError).toBeFalsy();
|
|
188
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('refuses a client that cannot be prompted under MCP_CONFIRM_MODE=refuse, naming the way through', async () => {
|
|
192
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
193
|
+
const harness = await setupHandlers();
|
|
194
|
+
const r = json(await harness.callTool('gog_api_call', { ...write, allowWrite: true }));
|
|
195
|
+
expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'api.call' });
|
|
196
|
+
expect(r.note).toMatch(/dryRun/);
|
|
197
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('token fallback: previews in full, refuses a changed payload, runs on phase 2', async () => {
|
|
201
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
202
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
203
|
+
const harness = await setupHandlers();
|
|
204
|
+
const args = { ...write, body: '{"a":1}', allowWrite: true };
|
|
205
|
+
const p1 = json(await harness.callTool('gog_api_call', args));
|
|
206
|
+
expect(p1).toMatchObject({
|
|
207
|
+
status: 'confirmation-required',
|
|
208
|
+
preview: {
|
|
209
|
+
api: 'calendar', version: 'v3', method: 'events.delete',
|
|
210
|
+
params: { calendarId: 'primary', eventId: 'e1', sendUpdates: 'all' },
|
|
211
|
+
body: { a: 1 },
|
|
212
|
+
},
|
|
213
|
+
instruction: CONFIRM_ACTION_INSTRUCTION,
|
|
214
|
+
});
|
|
215
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
216
|
+
expect(json(await harness.callTool('gog_api_call', { ...args, body: '{"a":2}', confirmToken: p1.confirmToken })))
|
|
217
|
+
.toMatchObject({ error: 'DRAFT_CHANGED' });
|
|
218
|
+
expect(json(await harness.callTool('gog_api_call', { ...args, params: '{"calendarId":"primary","eventId":"e2"}', confirmToken: p1.confirmToken })))
|
|
219
|
+
.toMatchObject({ error: 'DRAFT_CHANGED' });
|
|
220
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
221
|
+
await harness.callTool('gog_api_call', { ...args, confirmToken: p1.confirmToken });
|
|
222
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(vi.mocked(runner.run).mock.calls[0][0]).toContain('--allow-write');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
102
227
|
it('returns error text on failure', async () => {
|
|
103
228
|
vi.mocked(runner.run).mockRejectedValue(new Error('Call failed'));
|
|
104
229
|
const harness = await setupHandlers();
|
|
@@ -7,6 +7,10 @@ import { pos } from '../../src/argv.js';
|
|
|
7
7
|
vi.mock('../../src/runner.js');
|
|
8
8
|
|
|
9
9
|
const setupHandlers = () => createTestHarness(registerAppScriptTools);
|
|
10
|
+
// gog_appscript_run_function asks first (dispatch-gates-more.test.ts covers the prompt itself).
|
|
11
|
+
const acceptingHandlers = () => createTestHarness(registerAppScriptTools, {
|
|
12
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
13
|
+
});
|
|
10
14
|
|
|
11
15
|
beforeEach(() => vi.clearAllMocks());
|
|
12
16
|
|
|
@@ -114,14 +118,14 @@ describe('gog_appscript_versions', () => {
|
|
|
114
118
|
describe('gog_appscript_run_function', () => {
|
|
115
119
|
it('runs a deployed function', async () => {
|
|
116
120
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
117
|
-
const harness = await
|
|
121
|
+
const harness = await acceptingHandlers();
|
|
118
122
|
await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork' });
|
|
119
123
|
expect(runner.run).toHaveBeenCalledWith(['appscript', 'run', pos('S1'), pos('doWork')], { account: undefined });
|
|
120
124
|
});
|
|
121
125
|
|
|
122
126
|
it('passes params and --dev-mode', async () => {
|
|
123
127
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
124
|
-
const harness = await
|
|
128
|
+
const harness = await acceptingHandlers();
|
|
125
129
|
await harness.callTool('gog_appscript_run_function', {
|
|
126
130
|
scriptId: 'S1', functionName: 'doWork', params: '["a",1]', devMode: true,
|
|
127
131
|
});
|