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.
@@ -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.externalRecipients).toEqual(['outside@other.com']);
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.externalRecipients).toEqual(['outside@other.com']);
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.externalRecipients).toEqual(['stranger@random.example']);
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.externalRecipients).toEqual([]);
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.externalRecipients).toEqual(['a@example.com']);
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.externalRecipients).toEqual([]);
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.externalRecipients).toEqual(['person@user_config.trusted_domains']);
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.externalRecipients).toEqual(['not-an-email']);
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 (GOG_SEND_CONFIRM_FALLBACK=token). Elicitation stays the
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.GOG_SEND_CONFIRM_FALLBACK;
312
- delete process.env.GOG_CONFIRM_TTL_SECONDS;
313
- delete process.env.GOG_CONFIRM_SECRET;
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 the env unset, keeps the refusal and names the switch', async () => {
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('set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation');
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('GOG_SEND_CONFIRM_FALLBACK=token');
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.GOG_SEND_CONFIRM_FALLBACK = 'token';
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('GOG_SEND_CONFIRM_FALLBACK');
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.GOG_SEND_CONFIRM_FALLBACK = 'token';
367
- const fb = fallback('gct1.anything.here');
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
- describe('with GOG_SEND_CONFIRM_FALLBACK=token', () => {
374
- beforeEach(() => { process.env.GOG_SEND_CONFIRM_FALLBACK = 'token'; });
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(/^gct1\./);
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: 'message-id-rotated', dispatched: false, instruction: CONFIRM_INSTRUCTION });
404
- expect(body.note).toMatch(/messageId/);
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.GOG_CONFIRM_TTL_SECONDS = '60';
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, beforeEach, afterEach } from 'vitest';
2
- import {
3
- CONFIRM_TOKEN_TTL_DEFAULT_SECONDS,
4
- confirmTokenTtlSeconds,
5
- hashSendPayload,
6
- issueConfirmToken,
7
- resetConfirmTokenState,
8
- sendConfirmFallbackEnabled,
9
- verifyConfirmToken,
10
- type ConfirmBinding,
11
- } from '../src/send-confirm-token.js';
12
-
13
- const ORIGINAL_ENV = { ...process.env };
14
-
15
- const BINDING: ConfirmBinding = {
16
- tool: 'gog_gmail_drafts_send',
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
- const claims = Buffer.from('not json').toString('base64url');
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
  });
@@ -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
- beforeEach(() => vi.clearAllMocks());
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@b.com',
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@b.com', gmailNoSend: true },
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 setupHandlers();
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 setupHandlers();
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 setupHandlers();
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
  });