gogcli-mcp 4.2.5 → 4.4.0

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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +1340 -262
  4. package/dist/lib.js +1381 -260
  5. package/manifest.json +9 -2
  6. package/package.json +2 -2
  7. package/server.json +2 -2
  8. package/src/arg-guard.ts +68 -0
  9. package/src/argv.ts +27 -0
  10. package/src/attachment-root.ts +111 -0
  11. package/src/attachments.ts +1 -0
  12. package/src/blob-upload.ts +4 -2
  13. package/src/dispatch-confirmation.ts +277 -0
  14. package/src/file-roots.ts +66 -0
  15. package/src/gmail-dispatch-guard.ts +70 -30
  16. package/src/gmail-results.ts +21 -2
  17. package/src/lib.ts +15 -0
  18. package/src/run-path-guard.ts +118 -0
  19. package/src/runner.ts +86 -18
  20. package/src/send-confirm-token.ts +167 -0
  21. package/src/tools/api.ts +60 -7
  22. package/src/tools/appscript.ts +12 -8
  23. package/src/tools/auth.ts +13 -3
  24. package/src/tools/calendar.ts +178 -15
  25. package/src/tools/chat.ts +94 -18
  26. package/src/tools/classroom.ts +110 -28
  27. package/src/tools/contacts.ts +5 -3
  28. package/src/tools/docs.ts +8 -6
  29. package/src/tools/drive.ts +96 -20
  30. package/src/tools/gmail.ts +175 -24
  31. package/src/tools/sheets.ts +10 -8
  32. package/src/tools/slides.ts +14 -9
  33. package/src/tools/tasks.ts +7 -5
  34. package/src/tools/utils.ts +52 -6
  35. package/tests/arg-guard.test.ts +80 -0
  36. package/tests/attachment-root.test.ts +130 -0
  37. package/tests/attachments.test.ts +8 -0
  38. package/tests/blob-upload.test.ts +4 -3
  39. package/tests/file-roots.test.ts +101 -0
  40. package/tests/gmail-dispatch-guard.test.ts +270 -11
  41. package/tests/gmail-results.test.ts +35 -1
  42. package/tests/run-path-guard.test.ts +142 -0
  43. package/tests/runner.test.ts +136 -0
  44. package/tests/send-confirm-token.test.ts +200 -0
  45. package/tests/tools/api.test.ts +74 -8
  46. package/tests/tools/appscript.test.ts +34 -8
  47. package/tests/tools/auth.test.ts +44 -17
  48. package/tests/tools/calendar.test.ts +33 -28
  49. package/tests/tools/chat.test.ts +50 -21
  50. package/tests/tools/classroom.test.ts +43 -39
  51. package/tests/tools/contacts.test.ts +3 -2
  52. package/tests/tools/dispatch-gates.test.ts +396 -0
  53. package/tests/tools/docs.test.ts +44 -15
  54. package/tests/tools/drive.test.ts +110 -20
  55. package/tests/tools/gmail-confirm-token.test.ts +274 -0
  56. package/tests/tools/gmail.test.ts +227 -29
  57. package/tests/tools/run-tool-examples.test.ts +69 -0
  58. package/tests/tools/run-vets.test.ts +131 -0
  59. package/tests/tools/sheets.test.ts +16 -15
  60. package/tests/tools/slides.test.ts +47 -11
  61. package/tests/tools/tasks.test.ts +7 -6
  62. package/tests/tools/utils.test.ts +32 -31
  63. package/vitest.config.ts +5 -0
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerAuthTools, authToolsFor } from '../../src/tools/auth.js';
3
3
  import * as runner from '../../src/runner.js';
4
4
  import { createTestHarness } from '@chrischall/mcp-utils/test';
5
+ import { pos } from '../../src/argv.js';
5
6
 
6
7
  vi.mock('../../src/runner.js');
7
8
 
@@ -108,7 +109,7 @@ describe('gog_auth_add', () => {
108
109
  const harness = await setupHandlers();
109
110
  const result = await harness.callTool('gog_auth_add', { email: 'user@gmail.com' });
110
111
  expect(runner.run).toHaveBeenCalledWith(
111
- ['auth', 'add', 'user@gmail.com', '--services', 'all'],
112
+ ['auth', 'add', pos('user@gmail.com'), '--services', 'all'],
112
113
  { interactive: true, timeout: 300_000 },
113
114
  );
114
115
  expect(result.content[0].text).toBe('Authorization successful for user@gmail.com');
@@ -119,7 +120,7 @@ describe('gog_auth_add', () => {
119
120
  const harness = await setupHandlers();
120
121
  await harness.callTool('gog_auth_add', { email: 'user@gmail.com', services: 'sheets,gmail' });
121
122
  expect(runner.run).toHaveBeenCalledWith(
122
- ['auth', 'add', 'user@gmail.com', '--services', 'sheets,gmail'],
123
+ ['auth', 'add', pos('user@gmail.com'), '--services', 'sheets,gmail'],
123
124
  { interactive: true, timeout: 300_000 },
124
125
  );
125
126
  });
@@ -137,7 +138,7 @@ describe('gog_auth_add', () => {
137
138
  extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
138
139
  });
139
140
  expect(runner.run).toHaveBeenCalledWith(
140
- ['auth', 'add', 'user@gmail.com', '--services', 'sheets',
141
+ ['auth', 'add', pos('user@gmail.com'), '--services', 'sheets',
141
142
  '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly', '--force-consent'],
142
143
  { interactive: true, timeout: 300_000 },
143
144
  );
@@ -148,7 +149,7 @@ describe('gog_auth_add', () => {
148
149
  const harness = await setupHandlers();
149
150
  await harness.callTool('gog_auth_add', { email: 'user@gmail.com' });
150
151
  expect(runner.run).toHaveBeenCalledWith(
151
- ['auth', 'add', 'user@gmail.com', '--services', 'all'],
152
+ ['auth', 'add', pos('user@gmail.com'), '--services', 'all'],
152
153
  { interactive: true, timeout: 300_000 },
153
154
  );
154
155
  });
@@ -229,7 +230,7 @@ describe('gog_auth_add_url', () => {
229
230
  const harness = await setupHandlers();
230
231
  const result = await harness.callTool('gog_auth_add_url', { email: 'user@gmail.com' });
231
232
  expect(runner.run).toHaveBeenCalledWith(
232
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '1', '--services', 'all', '--force-consent'],
233
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '1', '--services', 'all', '--force-consent'],
233
234
  { redactMode: 'tokens' },
234
235
  );
235
236
  expect(result.content[0].text).toContain('accounts.google.com');
@@ -240,7 +241,7 @@ describe('gog_auth_add_url', () => {
240
241
  const harness = await setupHandlers();
241
242
  await harness.callTool('gog_auth_add_url', { email: 'user@gmail.com', services: 'gmail,drive' });
242
243
  expect(runner.run).toHaveBeenCalledWith(
243
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '1', '--services', 'gmail,drive', '--force-consent'],
244
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '1', '--services', 'gmail,drive', '--force-consent'],
244
245
  { redactMode: 'tokens' },
245
246
  );
246
247
  });
@@ -254,7 +255,7 @@ describe('gog_auth_add_url', () => {
254
255
  extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
255
256
  });
256
257
  expect(runner.run).toHaveBeenCalledWith(
257
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '1', '--services', 'sheets', '--force-consent',
258
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '1', '--services', 'sheets', '--force-consent',
258
259
  '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly'],
259
260
  { redactMode: 'tokens' },
260
261
  );
@@ -277,7 +278,7 @@ describe('gog_auth_add_complete', () => {
277
278
  redirectUrl: 'http://127.0.0.1:59436/oauth2/callback?code=abc&state=xyz',
278
279
  });
279
280
  expect(runner.run).toHaveBeenCalledWith(
280
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '2', '--auth-url',
281
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '2', '--auth-url',
281
282
  'http://127.0.0.1:59436/oauth2/callback?code=abc&state=xyz', '--services', 'all', '--force-consent'],
282
283
  );
283
284
  expect(result.content[0].text).toContain('stored');
@@ -292,7 +293,7 @@ describe('gog_auth_add_complete', () => {
292
293
  services: 'gmail,drive',
293
294
  });
294
295
  expect(runner.run).toHaveBeenCalledWith(
295
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '2', '--auth-url',
296
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '2', '--auth-url',
296
297
  'http://127.0.0.1/cb?code=c&state=s', '--services', 'gmail,drive', '--force-consent'],
297
298
  );
298
299
  });
@@ -307,7 +308,7 @@ describe('gog_auth_add_complete', () => {
307
308
  extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
308
309
  });
309
310
  expect(runner.run).toHaveBeenCalledWith(
310
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '2', '--auth-url',
311
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '2', '--auth-url',
311
312
  'http://127.0.0.1/cb?code=c&state=s', '--services', 'sheets', '--force-consent',
312
313
  '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly'],
313
314
  );
@@ -332,7 +333,7 @@ describe('authToolsFor (least-privilege default services)', () => {
332
333
  const harness = await gmailHarness();
333
334
  await harness.callTool('gog_auth_add_url', { email: 'u@x.com' });
334
335
  expect(runner.run).toHaveBeenCalledWith(
335
- ['auth', 'add', 'u@x.com', '--remote', '--step', '1', '--services', 'gmail', '--force-consent'],
336
+ ['auth', 'add', pos('u@x.com'), '--remote', '--step', '1', '--services', 'gmail', '--force-consent'],
336
337
  { redactMode: 'tokens' },
337
338
  );
338
339
  });
@@ -342,7 +343,7 @@ describe('authToolsFor (least-privilege default services)', () => {
342
343
  const harness = await gmailHarness();
343
344
  await harness.callTool('gog_auth_add', { email: 'u@x.com' });
344
345
  expect(runner.run).toHaveBeenCalledWith(
345
- ['auth', 'add', 'u@x.com', '--services', 'gmail'],
346
+ ['auth', 'add', pos('u@x.com'), '--services', 'gmail'],
346
347
  { interactive: true, timeout: 300_000 },
347
348
  );
348
349
  });
@@ -355,7 +356,7 @@ describe('authToolsFor (least-privilege default services)', () => {
355
356
  redirectUrl: 'http://127.0.0.1/cb?code=c&state=s',
356
357
  });
357
358
  expect(runner.run).toHaveBeenCalledWith(
358
- ['auth', 'add', 'u@x.com', '--remote', '--step', '2', '--auth-url',
359
+ ['auth', 'add', pos('u@x.com'), '--remote', '--step', '2', '--auth-url',
359
360
  'http://127.0.0.1/cb?code=c&state=s', '--services', 'gmail', '--force-consent'],
360
361
  );
361
362
  });
@@ -365,7 +366,7 @@ describe('authToolsFor (least-privilege default services)', () => {
365
366
  const harness = await gmailHarness();
366
367
  await harness.callTool('gog_auth_add_url', { email: 'u@x.com', services: 'gmail,drive' });
367
368
  expect(runner.run).toHaveBeenCalledWith(
368
- ['auth', 'add', 'u@x.com', '--remote', '--step', '1', '--services', 'gmail,drive', '--force-consent'],
369
+ ['auth', 'add', pos('u@x.com'), '--remote', '--step', '1', '--services', 'gmail,drive', '--force-consent'],
369
370
  { redactMode: 'tokens' },
370
371
  );
371
372
  });
@@ -381,10 +382,36 @@ describe('gog_auth_run', () => {
381
382
  });
382
383
 
383
384
  it('works with empty args array', async () => {
384
- vi.mocked(runner.run).mockResolvedValue('token info');
385
+ vi.mocked(runner.run).mockResolvedValue('accounts');
385
386
  const harness = await setupHandlers();
386
- await harness.callTool('gog_auth_run', { subcommand: 'tokens', args: [] });
387
- expect(runner.run).toHaveBeenCalledWith(['auth', 'tokens'], {});
387
+ await harness.callTool('gog_auth_run', { subcommand: 'list', args: [] });
388
+ expect(runner.run).toHaveBeenCalledWith(['auth', 'list'], {});
389
+ });
390
+
391
+ it.each(['list', 'status', 'services', 'remove', 'alias'])('allows %s', async (subcommand) => {
392
+ vi.mocked(runner.run).mockResolvedValue('ok');
393
+ const harness = await setupHandlers();
394
+ const result = await harness.callTool('gog_auth_run', { subcommand, args: [] });
395
+ expect(result.isError).toBeFalsy();
396
+ expect(runner.run).toHaveBeenCalledWith(['auth', subcommand], {});
397
+ });
398
+
399
+ // SEC-3: `auth tokens export --out <path>` writes a long-lived refresh token
400
+ // to disk, where an attach path can read it back out. Credentials and the
401
+ // keyring are the same class. Only the account-management subset is exposed.
402
+ it.each([
403
+ ['tokens', ['export', 'me@example.com', '--out=/tmp/t.json']],
404
+ ['tokens', ['import', '/tmp/t.json']],
405
+ ['credentials', ['list']],
406
+ ['keyring', []],
407
+ ['add', ['me@example.com']],
408
+ ['service-account', ['set']],
409
+ ])('refuses auth %s %j', async (subcommand, args) => {
410
+ const harness = await setupHandlers();
411
+ const result = await harness.callTool('gog_auth_run', { subcommand, args });
412
+ expect(result.isError).toBe(true);
413
+ expect(result.content[0].text).toMatch(/not available through gog_auth_run/);
414
+ expect(runner.run).not.toHaveBeenCalled();
388
415
  });
389
416
 
390
417
  it('returns error text on failure', async () => {
@@ -2,15 +2,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerCalendarTools, CALENDAR_EVENTS_COMPACT_FIELDS } from '../../src/tools/calendar.js';
3
3
  import * as runner from '../../src/runner.js';
4
4
  import { createTestHarness } from '@chrischall/mcp-utils/test';
5
+ import { pos } from '../../src/argv.js';
6
+ import type { GogArg } from '../../src/runner.js';
5
7
 
6
8
  vi.mock('../../src/runner.js');
7
9
 
8
- const setupHandlers = () => createTestHarness(registerCalendarTools);
10
+ // The dispatch rail asks before guest-visible / outbound calls; this client accepts.
11
+ const setupHandlers = () => createTestHarness(registerCalendarTools, {
12
+ elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
13
+ });
9
14
 
10
15
  // gog_calendar_events answers in the compact rung by default, so every call
11
16
  // carries the mask. These keep the window-flag tests below about window flags
12
17
  // while still asserting the shipped default rather than an opted-out view.
13
- const evArgs = (...extra: string[]) =>
18
+ const evArgs = (...extra: GogArg[]) =>
14
19
  ['calendar', 'events', ...extra, `--fields=${CALENDAR_EVENTS_COMPACT_FIELDS}`];
15
20
  const evOpts = { account: undefined, fieldsMask: CALENDAR_EVENTS_COMPACT_FIELDS };
16
21
 
@@ -67,7 +72,7 @@ describe('gog_calendar_events', () => {
67
72
  query: 'standup',
68
73
  all: true,
69
74
  });
70
- expect(runner.run).toHaveBeenCalledWith(evArgs('primary', '--from=2026-01-01', '--to=2026-01-31', '--query=standup', '--all'), evOpts);
75
+ expect(runner.run).toHaveBeenCalledWith(evArgs(pos('primary'), '--from=2026-01-01', '--to=2026-01-31', '--query=standup', '--all'), evOpts);
71
76
  });
72
77
 
73
78
  it('appends --today on its own', async () => {
@@ -118,7 +123,7 @@ describe('gog_calendar_get', () => {
118
123
  vi.mocked(runner.run).mockResolvedValue('{"id":"evt1"}');
119
124
  const harness = await setupHandlers();
120
125
  await harness.callTool('gog_calendar_get', { calendarId: 'primary', eventId: 'evt1' });
121
- expect(runner.run).toHaveBeenCalledWith(['calendar', 'event', 'primary', 'evt1'], { account: undefined });
126
+ expect(runner.run).toHaveBeenCalledWith(['calendar', 'event', pos('primary'), pos('evt1')], { account: undefined });
122
127
  });
123
128
 
124
129
  it('appends --timezone when provided', async () => {
@@ -126,7 +131,7 @@ describe('gog_calendar_get', () => {
126
131
  const harness = await setupHandlers();
127
132
  await harness.callTool('gog_calendar_get', { calendarId: 'primary', eventId: 'evt1', timezone: 'local' });
128
133
  expect(runner.run).toHaveBeenCalledWith(
129
- ['calendar', 'event', 'primary', 'evt1', '--timezone=local'],
134
+ ['calendar', 'event', pos('primary'), pos('evt1'), '--timezone=local'],
130
135
  { account: undefined },
131
136
  );
132
137
  });
@@ -150,7 +155,7 @@ describe('gog_calendar_create', () => {
150
155
  to: '2026-04-14T09:30:00Z',
151
156
  });
152
157
  expect(runner.run).toHaveBeenCalledWith(
153
- ['calendar', 'create', 'primary', '--summary=Standup', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z'],
158
+ ['calendar', 'create', pos('primary'), '--summary=Standup', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z'],
154
159
  { account: undefined },
155
160
  );
156
161
  });
@@ -171,7 +176,7 @@ describe('gog_calendar_create', () => {
171
176
  });
172
177
  expect(runner.run).toHaveBeenCalledWith(
173
178
  [
174
- 'calendar', 'create', 'primary',
179
+ 'calendar', 'create', pos('primary'),
175
180
  '--summary=All-day', '--from=2026-04-14', '--to=2026-04-15',
176
181
  '--description=Desc', '--location=NYC', '--attendees=a@b.com,c@d.com', '--all-day',
177
182
  '--timezone=America/New_York',
@@ -195,7 +200,7 @@ describe('gog_calendar_create', () => {
195
200
  });
196
201
  expect(runner.run).toHaveBeenCalledWith(
197
202
  [
198
- 'calendar', 'create', 'primary',
203
+ 'calendar', 'create', pos('primary'),
199
204
  '--summary=Sync', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
200
205
  '--with-zoom',
201
206
  ],
@@ -210,7 +215,7 @@ describe('gog_calendar_create', () => {
210
215
  calendarId: 'primary', summary: 's', from: 'f', to: 't', withZoom: false,
211
216
  });
212
217
  expect(runner.run).toHaveBeenCalledWith(
213
- ['calendar', 'create', 'primary', '--summary=s', '--from=f', '--to=t'],
218
+ ['calendar', 'create', pos('primary'), '--summary=s', '--from=f', '--to=t'],
214
219
  { account: undefined },
215
220
  );
216
221
  });
@@ -233,7 +238,7 @@ describe('gog_calendar_update', () => {
233
238
  summary: 'New Title',
234
239
  });
235
240
  expect(runner.run).toHaveBeenCalledWith(
236
- ['calendar', 'update', 'primary', 'evt1', '--summary=New Title'],
241
+ ['calendar', 'update', pos('primary'), pos('evt1'), '--summary=New Title'],
237
242
  { account: undefined },
238
243
  );
239
244
  });
@@ -246,12 +251,12 @@ describe('gog_calendar_update', () => {
246
251
  calendarId: 'primary', eventId: 'evt1', attachments: ['https://drive.google.com/file/d/a', 'https://x.test/b.pdf'],
247
252
  });
248
253
  expect(runner.run).toHaveBeenCalledWith(
249
- ['calendar', 'update', 'primary', 'evt1', '--attachment=https://drive.google.com/file/d/a', '--attachment=https://x.test/b.pdf'],
254
+ ['calendar', 'update', pos('primary'), pos('evt1'), '--attachment=https://drive.google.com/file/d/a', '--attachment=https://x.test/b.pdf'],
250
255
  { account: undefined },
251
256
  );
252
257
  await harness.callTool('gog_calendar_update', { calendarId: 'primary', eventId: 'evt1', attachments: [''] });
253
258
  expect(runner.run).toHaveBeenCalledWith(
254
- ['calendar', 'update', 'primary', 'evt1', '--attachment='],
259
+ ['calendar', 'update', pos('primary'), pos('evt1'), '--attachment='],
255
260
  { account: undefined },
256
261
  );
257
262
  });
@@ -271,7 +276,7 @@ describe('gog_calendar_update', () => {
271
276
  });
272
277
  expect(runner.run).toHaveBeenCalledWith(
273
278
  [
274
- 'calendar', 'update', 'primary', 'evt1',
279
+ 'calendar', 'update', pos('primary'), pos('evt1'),
275
280
  '--summary=New', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T10:00:00Z',
276
281
  '--description=Desc', '--location=NYC', '--attendees=a@b.com',
277
282
  ],
@@ -290,7 +295,7 @@ describe('gog_calendar_update', () => {
290
295
  });
291
296
  expect(runner.run).toHaveBeenCalledWith(
292
297
  [
293
- 'calendar', 'update', 'primary', 'evt1',
298
+ 'calendar', 'update', pos('primary'), pos('evt1'),
294
299
  '--add-attendee=room@resource.calendar.google.com;resource,x@y.com;optional',
295
300
  ],
296
301
  { account: undefined },
@@ -305,7 +310,7 @@ describe('gog_calendar_update', () => {
305
310
  calendarId: 'primary', eventId: 'evt1', withZoom: true,
306
311
  });
307
312
  expect(runner.run).toHaveBeenCalledWith(
308
- ['calendar', 'update', 'primary', 'evt1', '--with-zoom'],
313
+ ['calendar', 'update', pos('primary'), pos('evt1'), '--with-zoom'],
309
314
  { account: undefined },
310
315
  );
311
316
 
@@ -315,7 +320,7 @@ describe('gog_calendar_update', () => {
315
320
  calendarId: 'primary', eventId: 'evt1', regenerateZoom: true,
316
321
  });
317
322
  expect(runner.run).toHaveBeenCalledWith(
318
- ['calendar', 'update', 'primary', 'evt1', '--regenerate-zoom'],
323
+ ['calendar', 'update', pos('primary'), pos('evt1'), '--regenerate-zoom'],
319
324
  { account: undefined },
320
325
  );
321
326
 
@@ -325,7 +330,7 @@ describe('gog_calendar_update', () => {
325
330
  calendarId: 'primary', eventId: 'evt1', removeZoom: true,
326
331
  });
327
332
  expect(runner.run).toHaveBeenCalledWith(
328
- ['calendar', 'update', 'primary', 'evt1', '--remove-zoom'],
333
+ ['calendar', 'update', pos('primary'), pos('evt1'), '--remove-zoom'],
329
334
  { account: undefined },
330
335
  );
331
336
  });
@@ -338,7 +343,7 @@ describe('gog_calendar_update', () => {
338
343
  calendarId: 'primary', eventId: 'evt1', removeMeet: true,
339
344
  });
340
345
  expect(runner.run).toHaveBeenCalledWith(
341
- ['calendar', 'update', 'primary', 'evt1', '--remove-meet'],
346
+ ['calendar', 'update', pos('primary'), pos('evt1'), '--remove-meet'],
342
347
  { account: undefined },
343
348
  );
344
349
  });
@@ -351,7 +356,7 @@ describe('gog_calendar_update', () => {
351
356
  withZoom: false, regenerateZoom: false, removeZoom: false,
352
357
  });
353
358
  expect(runner.run).toHaveBeenCalledWith(
354
- ['calendar', 'update', 'primary', 'evt1'],
359
+ ['calendar', 'update', pos('primary'), pos('evt1')],
355
360
  { account: undefined },
356
361
  );
357
362
  });
@@ -369,7 +374,7 @@ describe('gog_calendar_delete', () => {
369
374
  vi.mocked(runner.run).mockResolvedValue('{}');
370
375
  const harness = await setupHandlers();
371
376
  await harness.callTool('gog_calendar_delete', { calendarId: 'primary', eventId: 'evt1' });
372
- expect(runner.run).toHaveBeenCalledWith(['calendar', 'delete', 'primary', 'evt1', '--force'], { account: undefined });
377
+ expect(runner.run).toHaveBeenCalledWith(['calendar', 'delete', pos('primary'), pos('evt1'), '--force'], { account: undefined });
373
378
  });
374
379
 
375
380
  it('returns error text on failure', async () => {
@@ -386,7 +391,7 @@ describe('gog_calendar_respond', () => {
386
391
  const harness = await setupHandlers();
387
392
  await harness.callTool('gog_calendar_respond', { calendarId: 'primary', eventId: 'evt1', status: 'accepted' });
388
393
  expect(runner.run).toHaveBeenCalledWith(
389
- ['calendar', 'respond', 'primary', 'evt1', '--status=accepted'],
394
+ ['calendar', 'respond', pos('primary'), pos('evt1'), '--status=accepted'],
390
395
  { account: undefined },
391
396
  );
392
397
  });
@@ -401,7 +406,7 @@ describe('gog_calendar_respond', () => {
401
406
  comment: 'Can\'t make it',
402
407
  });
403
408
  expect(runner.run).toHaveBeenCalledWith(
404
- ['calendar', 'respond', 'primary', 'evt1', '--status=declined', '--comment=Can\'t make it'],
409
+ ['calendar', 'respond', pos('primary'), pos('evt1'), '--status=declined', '--comment=Can\'t make it'],
405
410
  { account: undefined },
406
411
  );
407
412
  });
@@ -500,7 +505,7 @@ describe('gog_calendar_create reminders', () => {
500
505
  reminders: ['popup:30m', 'email:1d'],
501
506
  });
502
507
  expect(runner.run).toHaveBeenCalledWith(
503
- ['calendar', 'create', 'primary', '--summary=Standup', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
508
+ ['calendar', 'create', pos('primary'), '--summary=Standup', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
504
509
  '--reminder=popup:30m', '--reminder=email:1d'],
505
510
  { account: undefined },
506
511
  );
@@ -517,7 +522,7 @@ describe('gog_calendar_create reminders', () => {
517
522
  noReminders: true,
518
523
  });
519
524
  expect(runner.run).toHaveBeenCalledWith(
520
- ['calendar', 'create', 'primary', '--summary=Quiet', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
525
+ ['calendar', 'create', pos('primary'), '--summary=Quiet', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
521
526
  '--no-reminders'],
522
527
  { account: undefined },
523
528
  );
@@ -559,7 +564,7 @@ describe('gog_calendar_update reminders', () => {
559
564
  calendarId: 'primary', eventId: 'evt1', reminders: ['popup:15m'],
560
565
  });
561
566
  expect(runner.run).toHaveBeenCalledWith(
562
- ['calendar', 'update', 'primary', 'evt1', '--reminder=popup:15m'],
567
+ ['calendar', 'update', pos('primary'), pos('evt1'), '--reminder=popup:15m'],
563
568
  { account: undefined },
564
569
  );
565
570
  });
@@ -573,7 +578,7 @@ describe('gog_calendar_update reminders', () => {
573
578
  calendarId: 'primary', eventId: 'evt1', reminders: [],
574
579
  });
575
580
  expect(runner.run).toHaveBeenCalledWith(
576
- ['calendar', 'update', 'primary', 'evt1', '--reminder='],
581
+ ['calendar', 'update', pos('primary'), pos('evt1'), '--reminder='],
577
582
  { account: undefined },
578
583
  );
579
584
  });
@@ -585,7 +590,7 @@ describe('gog_calendar_update reminders', () => {
585
590
  calendarId: 'primary', eventId: 'evt1', noReminders: true,
586
591
  });
587
592
  expect(runner.run).toHaveBeenCalledWith(
588
- ['calendar', 'update', 'primary', 'evt1', '--no-reminders'],
593
+ ['calendar', 'update', pos('primary'), pos('evt1'), '--no-reminders'],
589
594
  { account: undefined },
590
595
  );
591
596
  });
@@ -597,7 +602,7 @@ describe('gog_calendar_update reminders', () => {
597
602
  calendarId: 'primary', eventId: 'evt1', summary: 'Renamed',
598
603
  });
599
604
  expect(runner.run).toHaveBeenCalledWith(
600
- ['calendar', 'update', 'primary', 'evt1', '--summary=Renamed'],
605
+ ['calendar', 'update', pos('primary'), pos('evt1'), '--summary=Renamed'],
601
606
  { account: undefined },
602
607
  );
603
608
  });
@@ -2,10 +2,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerChatTools } from '../../src/tools/chat.js';
3
3
  import * as runner from '../../src/runner.js';
4
4
  import { createTestHarness } from '@chrischall/mcp-utils/test';
5
+ import { pos } from '../../src/argv.js';
5
6
 
6
7
  vi.mock('../../src/runner.js');
7
8
 
8
- const setupHandlers = () => createTestHarness(registerChatTools);
9
+ // The dispatch rail asks before guest-visible / outbound calls; this client accepts.
10
+ const setupHandlers = () => createTestHarness(registerChatTools, {
11
+ elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
12
+ });
9
13
 
10
14
  beforeEach(() => vi.clearAllMocks());
11
15
 
@@ -58,7 +62,7 @@ describe('gog_chat_spaces_find', () => {
58
62
  vi.mocked(runner.run).mockResolvedValue('{}');
59
63
  const harness = await setupHandlers();
60
64
  await harness.callTool('gog_chat_spaces_find', { displayName: 'Team' });
61
- expect(runner.run).toHaveBeenCalledWith(['chat', 'spaces', 'find', 'Team'], { account: undefined });
65
+ expect(runner.run).toHaveBeenCalledWith(['chat', 'spaces', 'find', pos('Team')], { account: undefined });
62
66
  });
63
67
 
64
68
  it('passes --exact and --max', async () => {
@@ -66,7 +70,7 @@ describe('gog_chat_spaces_find', () => {
66
70
  const harness = await setupHandlers();
67
71
  await harness.callTool('gog_chat_spaces_find', { displayName: 'Team', exact: true, max: 5 });
68
72
  expect(runner.run).toHaveBeenCalledWith(
69
- ['chat', 'spaces', 'find', 'Team', '--exact', '--max=5'],
73
+ ['chat', 'spaces', 'find', pos('Team'), '--exact', '--max=5'],
70
74
  { account: undefined },
71
75
  );
72
76
  });
@@ -78,7 +82,7 @@ describe('gog_chat_spaces_create', () => {
78
82
  const harness = await setupHandlers();
79
83
  await harness.callTool('gog_chat_spaces_create', { displayName: 'Launch', members: ['a@b.com', 'c@d.com'] });
80
84
  expect(runner.run).toHaveBeenCalledWith(
81
- ['chat', 'spaces', 'create', 'Launch', '--member=a@b.com', '--member=c@d.com'],
85
+ ['chat', 'spaces', 'create', pos('Launch'), '--member=a@b.com', '--member=c@d.com'],
82
86
  { account: undefined },
83
87
  );
84
88
  });
@@ -87,7 +91,7 @@ describe('gog_chat_spaces_create', () => {
87
91
  vi.mocked(runner.run).mockResolvedValue('{}');
88
92
  const harness = await setupHandlers();
89
93
  await harness.callTool('gog_chat_spaces_create', { displayName: 'Solo' });
90
- expect(runner.run).toHaveBeenCalledWith(['chat', 'spaces', 'create', 'Solo'], { account: undefined });
94
+ expect(runner.run).toHaveBeenCalledWith(['chat', 'spaces', 'create', pos('Solo')], { account: undefined });
91
95
  });
92
96
  });
93
97
 
@@ -97,7 +101,7 @@ describe('gog_chat_threads_list', () => {
97
101
  const harness = await setupHandlers();
98
102
  await harness.callTool('gog_chat_threads_list', { space: 'spaces/AAA', max: 10 });
99
103
  expect(runner.run).toHaveBeenCalledWith(
100
- ['chat', 'threads', 'list', 'spaces/AAA', '--max=10'],
104
+ ['chat', 'threads', 'list', pos('spaces/AAA'), '--max=10'],
101
105
  { account: undefined },
102
106
  );
103
107
  });
@@ -108,7 +112,7 @@ describe('gog_chat_messages_list', () => {
108
112
  vi.mocked(runner.run).mockResolvedValue('{}');
109
113
  const harness = await setupHandlers();
110
114
  await harness.callTool('gog_chat_messages_list', { space: 'spaces/AAA' });
111
- expect(runner.run).toHaveBeenCalledWith(['chat', 'messages', 'list', 'spaces/AAA'], { account: undefined });
115
+ expect(runner.run).toHaveBeenCalledWith(['chat', 'messages', 'list', pos('spaces/AAA')], { account: undefined });
112
116
  });
113
117
 
114
118
  it('filters by thread, unread and order', async () => {
@@ -118,7 +122,7 @@ describe('gog_chat_messages_list', () => {
118
122
  space: 'spaces/AAA', thread: 'spaces/AAA/threads/T', unread: true, order: 'createTime desc', max: 50,
119
123
  });
120
124
  expect(runner.run).toHaveBeenCalledWith(
121
- ['chat', 'messages', 'list', 'spaces/AAA', '--thread=spaces/AAA/threads/T', '--unread',
125
+ ['chat', 'messages', 'list', pos('spaces/AAA'), '--thread=spaces/AAA/threads/T', '--unread',
122
126
  '--order=createTime desc', '--max=50'],
123
127
  { account: undefined },
124
128
  );
@@ -138,7 +142,7 @@ describe('gog_chat_messages_search', () => {
138
142
  const harness = await setupHandlers();
139
143
  await harness.callTool('gog_chat_messages_search', { query: 'project decision' });
140
144
  expect(runner.run).toHaveBeenCalledWith(
141
- ['chat', 'messages', 'search', 'project decision'],
145
+ ['chat', 'messages', 'search', pos('project decision')],
142
146
  { account: undefined },
143
147
  );
144
148
  });
@@ -156,7 +160,7 @@ describe('gog_chat_messages_search', () => {
156
160
  all: true,
157
161
  });
158
162
  expect(runner.run).toHaveBeenCalledWith(
159
- ['chat', 'messages', 'search', 'from:alice@example.com budget', '--order=create_time desc',
163
+ ['chat', 'messages', 'search', pos('from:alice@example.com budget'), '--order=create_time desc',
160
164
  '--view=full', '--markup=markdown', '--max=100', '--page=tok', '--all'],
161
165
  { account: undefined },
162
166
  );
@@ -198,7 +202,7 @@ describe('gog_chat_messages_send', () => {
198
202
  const harness = await setupHandlers();
199
203
  await harness.callTool('gog_chat_messages_send', { space: 'spaces/AAA', text: 'hi' });
200
204
  expect(runner.run).toHaveBeenCalledWith(
201
- ['chat', 'messages', 'send', 'spaces/AAA', '--text=hi'],
205
+ ['chat', 'messages', 'send', pos('spaces/AAA'), '--text=hi'],
202
206
  { account: undefined },
203
207
  );
204
208
  });
@@ -210,7 +214,7 @@ describe('gog_chat_messages_send', () => {
210
214
  space: 'spaces/AAA', text: 'see this', thread: 'spaces/AAA/threads/T', attach: ['/tmp/a.png'],
211
215
  });
212
216
  expect(runner.run).toHaveBeenCalledWith(
213
- ['chat', 'messages', 'send', 'spaces/AAA', '--text=see this', '--thread=spaces/AAA/threads/T', '--attach=/tmp/a.png'],
217
+ ['chat', 'messages', 'send', pos('spaces/AAA'), '--text=see this', '--thread=spaces/AAA/threads/T', '--attach=/tmp/a.png'],
214
218
  { account: undefined },
215
219
  );
216
220
  });
@@ -234,7 +238,7 @@ describe('gog_chat_messages_send', () => {
234
238
  const harness = await setupHandlers();
235
239
  await harness.callTool('gog_chat_messages_send', { space: 'spaces/AAA', attach: ['/tmp/a.png'] });
236
240
  expect(runner.run).toHaveBeenCalledWith(
237
- ['chat', 'messages', 'send', 'spaces/AAA', '--attach=/tmp/a.png'],
241
+ ['chat', 'messages', 'send', pos('spaces/AAA'), '--attach=/tmp/a.png'],
238
242
  { account: undefined },
239
243
  );
240
244
  });
@@ -253,7 +257,7 @@ describe('gog_chat_dm_send', () => {
253
257
  const harness = await setupHandlers();
254
258
  await harness.callTool('gog_chat_dm_send', { email: 'a@b.com', text: 'hi' });
255
259
  expect(runner.run).toHaveBeenCalledWith(
256
- ['chat', 'dm', 'send', 'a@b.com', '--text=hi'],
260
+ ['chat', 'dm', 'send', pos('a@b.com'), '--text=hi'],
257
261
  { account: undefined },
258
262
  );
259
263
  });
@@ -263,7 +267,7 @@ describe('gog_chat_dm_send', () => {
263
267
  const harness = await setupHandlers();
264
268
  await harness.callTool('gog_chat_dm_send', { email: 'a@b.com', text: 'hi', thread: 'spaces/D/threads/T' });
265
269
  expect(runner.run).toHaveBeenCalledWith(
266
- ['chat', 'dm', 'send', 'a@b.com', '--text=hi', '--thread=spaces/D/threads/T'],
270
+ ['chat', 'dm', 'send', pos('a@b.com'), '--text=hi', '--thread=spaces/D/threads/T'],
267
271
  { account: undefined },
268
272
  );
269
273
  });
@@ -274,7 +278,7 @@ describe('gog_chat_dm_space', () => {
274
278
  vi.mocked(runner.run).mockResolvedValue('{}');
275
279
  const harness = await setupHandlers();
276
280
  await harness.callTool('gog_chat_dm_space', { email: 'a@b.com' });
277
- expect(runner.run).toHaveBeenCalledWith(['chat', 'dm', 'space', 'a@b.com'], { account: undefined });
281
+ expect(runner.run).toHaveBeenCalledWith(['chat', 'dm', 'space', pos('a@b.com')], { account: undefined });
278
282
  });
279
283
  });
280
284
 
@@ -284,7 +288,7 @@ describe('gog_chat_reactions_list', () => {
284
288
  const harness = await setupHandlers();
285
289
  await harness.callTool('gog_chat_reactions_list', { message: 'MSG', space: 'spaces/AAA', max: 10 });
286
290
  expect(runner.run).toHaveBeenCalledWith(
287
- ['chat', 'messages', 'reactions', 'list', 'MSG', '--space=spaces/AAA', '--max=10'],
291
+ ['chat', 'messages', 'reactions', 'list', pos('MSG'), '--space=spaces/AAA', '--max=10'],
288
292
  { account: undefined },
289
293
  );
290
294
  });
@@ -294,7 +298,7 @@ describe('gog_chat_reactions_list', () => {
294
298
  const harness = await setupHandlers();
295
299
  await harness.callTool('gog_chat_reactions_list', { message: 'spaces/AAA/messages/M' });
296
300
  expect(runner.run).toHaveBeenCalledWith(
297
- ['chat', 'messages', 'reactions', 'list', 'spaces/AAA/messages/M'],
301
+ ['chat', 'messages', 'reactions', 'list', pos('spaces/AAA/messages/M')],
298
302
  { account: undefined },
299
303
  );
300
304
  });
@@ -306,7 +310,7 @@ describe('gog_chat_reactions_create', () => {
306
310
  const harness = await setupHandlers();
307
311
  await harness.callTool('gog_chat_reactions_create', { message: 'spaces/AAA/messages/M', emoji: '👍' });
308
312
  expect(runner.run).toHaveBeenCalledWith(
309
- ['chat', 'messages', 'reactions', 'create', 'spaces/AAA/messages/M', '👍'],
313
+ ['chat', 'messages', 'reactions', 'create', pos('spaces/AAA/messages/M'), pos('👍')],
310
314
  { account: undefined },
311
315
  );
312
316
  });
@@ -316,7 +320,7 @@ describe('gog_chat_reactions_create', () => {
316
320
  const harness = await setupHandlers();
317
321
  await harness.callTool('gog_chat_reactions_create', { message: 'M', emoji: '👍', space: 'spaces/AAA' });
318
322
  expect(runner.run).toHaveBeenCalledWith(
319
- ['chat', 'messages', 'reactions', 'create', 'M', '👍', '--space=spaces/AAA'],
323
+ ['chat', 'messages', 'reactions', 'create', pos('M'), pos('👍'), '--space=spaces/AAA'],
320
324
  { account: undefined },
321
325
  );
322
326
  });
@@ -328,7 +332,7 @@ describe('gog_chat_reactions_delete', () => {
328
332
  const harness = await setupHandlers();
329
333
  await harness.callTool('gog_chat_reactions_delete', { reaction: 'spaces/AAA/messages/M/reactions/R' });
330
334
  expect(runner.run).toHaveBeenCalledWith(
331
- ['chat', 'messages', 'reactions', 'delete', 'spaces/AAA/messages/M/reactions/R'],
335
+ ['chat', 'messages', 'reactions', 'delete', pos('spaces/AAA/messages/M/reactions/R')],
332
336
  { account: undefined },
333
337
  );
334
338
  });
@@ -342,3 +346,28 @@ describe('gog_chat_run', () => {
342
346
  expect(runner.run).toHaveBeenCalledWith(['chat', 'spaces', 'list'], { account: undefined });
343
347
  });
344
348
  });
349
+
350
+ // SEC-3/SEC-4: every model-supplied server path must resolve inside an
351
+ // operator-configured root (GOG_FILE_ROOTS). The suite runs with '/' so the
352
+ // arg-shape tests can use any path; these narrow it.
353
+ async function withFileRoots<T>(roots: string, fn: () => Promise<T>): Promise<T> {
354
+ const prev = process.env.GOG_FILE_ROOTS;
355
+ process.env.GOG_FILE_ROOTS = roots;
356
+ try {
357
+ return await fn();
358
+ } finally {
359
+ process.env.GOG_FILE_ROOTS = prev;
360
+ }
361
+ }
362
+
363
+ describe('gog_chat_messages_send — server paths', () => {
364
+ it('refuses an attach path outside GOG_FILE_ROOTS', async () => {
365
+ const harness = await setupHandlers();
366
+ const result = await withFileRoots('/srv/gog-files', () => harness.callTool('gog_chat_messages_send', {
367
+ space: 'spaces/AAA', attach: ['/etc/passwd'],
368
+ }));
369
+ expect(result.isError).toBe(true);
370
+ expect(result.content[0].text).toContain('attach "/etc/passwd" is outside');
371
+ expect(runner.run).not.toHaveBeenCalled();
372
+ });
373
+ });