gogcli-mcp 4.2.4 → 4.3.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 (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +714 -216
  4. package/dist/lib.js +751 -217
  5. package/manifest.json +9 -2
  6. package/mint.yaml +1 -1
  7. package/package.json +2 -2
  8. package/server.json +2 -2
  9. package/src/arg-guard.ts +68 -0
  10. package/src/argv.ts +27 -0
  11. package/src/attachment-root.ts +111 -0
  12. package/src/attachments.ts +1 -0
  13. package/src/blob-upload.ts +4 -2
  14. package/src/file-roots.ts +66 -0
  15. package/src/gmail-dispatch-guard.ts +56 -16
  16. package/src/gmail-results.ts +21 -2
  17. package/src/lib.ts +6 -0
  18. package/src/run-path-guard.ts +118 -0
  19. package/src/runner.ts +87 -19
  20. package/src/tools/api.ts +39 -7
  21. package/src/tools/appscript.ts +12 -8
  22. package/src/tools/auth.ts +13 -3
  23. package/src/tools/calendar.ts +10 -8
  24. package/src/tools/chat.ts +16 -13
  25. package/src/tools/classroom.ts +27 -25
  26. package/src/tools/contacts.ts +5 -3
  27. package/src/tools/docs.ts +8 -6
  28. package/src/tools/drive.ts +42 -18
  29. package/src/tools/gmail.ts +71 -11
  30. package/src/tools/sheets.ts +10 -8
  31. package/src/tools/slides.ts +14 -9
  32. package/src/tools/tasks.ts +7 -5
  33. package/src/tools/utils.ts +52 -6
  34. package/tests/arg-guard.test.ts +80 -0
  35. package/tests/attachment-root.test.ts +130 -0
  36. package/tests/attachments.test.ts +8 -0
  37. package/tests/blob-upload.test.ts +4 -3
  38. package/tests/file-roots.test.ts +101 -0
  39. package/tests/gmail-dispatch-guard.test.ts +46 -0
  40. package/tests/gmail-results.test.ts +35 -1
  41. package/tests/run-path-guard.test.ts +142 -0
  42. package/tests/runner.test.ts +136 -0
  43. package/tests/tools/api.test.ts +74 -8
  44. package/tests/tools/appscript.test.ts +34 -8
  45. package/tests/tools/auth.test.ts +44 -17
  46. package/tests/tools/calendar.test.ts +29 -27
  47. package/tests/tools/chat.test.ts +46 -20
  48. package/tests/tools/classroom.test.ts +39 -38
  49. package/tests/tools/contacts.test.ts +3 -2
  50. package/tests/tools/docs.test.ts +44 -15
  51. package/tests/tools/drive.test.ts +106 -19
  52. package/tests/tools/gmail.test.ts +227 -29
  53. package/tests/tools/run-tool-examples.test.ts +69 -0
  54. package/tests/tools/sheets.test.ts +16 -15
  55. package/tests/tools/slides.test.ts +47 -11
  56. package/tests/tools/tasks.test.ts +7 -6
  57. package/tests/tools/utils.test.ts +32 -31
  58. package/vitest.config.ts +5 -0
@@ -1,7 +1,8 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { registerDriveTools, DRIVE_LS_COMPACT_FIELDS } from '../../src/tools/drive.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
 
@@ -111,7 +112,7 @@ describe('gog_drive_search', () => {
111
112
  const harness = await setupHandlers();
112
113
  await harness.callTool('gog_drive_search', { query: 'budget' });
113
114
  expect(runner.run).toHaveBeenCalledWith(
114
- ['drive', 'search', 'budget'],
115
+ ['drive', 'search', pos('budget')],
115
116
  { account: undefined, stripMedia: true },
116
117
  );
117
118
  });
@@ -121,7 +122,7 @@ describe('gog_drive_search', () => {
121
122
  const harness = await setupHandlers();
122
123
  await harness.callTool('gog_drive_search', { query: 'budget', view: 'full' });
123
124
  expect(runner.run).toHaveBeenCalledWith(
124
- ['drive', 'search', 'budget'],
125
+ ['drive', 'search', pos('budget')],
125
126
  { account: undefined, stripMedia: false },
126
127
  );
127
128
  });
@@ -142,14 +143,14 @@ describe('gog_drive_get', () => {
142
143
  vi.mocked(runner.run).mockResolvedValue('{}');
143
144
  const harness = await setupHandlers();
144
145
  await harness.callTool('gog_drive_get', { fileId: 'f1' });
145
- expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'f1'], { account: undefined, stripMedia: true });
146
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'get', pos('f1')], { account: undefined, stripMedia: true });
146
147
  });
147
148
 
148
149
  it('keeps everything for the full view', async () => {
149
150
  vi.mocked(runner.run).mockResolvedValue('{}');
150
151
  const harness = await setupHandlers();
151
152
  await harness.callTool('gog_drive_get', { fileId: 'f1', view: 'full' });
152
- expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'f1'], { account: undefined, stripMedia: false });
153
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'get', pos('f1')], { account: undefined, stripMedia: false });
153
154
  });
154
155
 
155
156
 
@@ -166,7 +167,7 @@ describe('gog_drive_mkdir', () => {
166
167
  vi.mocked(runner.run).mockResolvedValue('{"id":"new-folder"}');
167
168
  const harness = await setupHandlers();
168
169
  await harness.callTool('gog_drive_mkdir', { name: 'Reports' });
169
- expect(runner.run).toHaveBeenCalledWith(['drive', 'mkdir', 'Reports'], { account: undefined });
170
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'mkdir', pos('Reports')], { account: undefined });
170
171
  });
171
172
 
172
173
  it('returns error text on failure', async () => {
@@ -182,7 +183,7 @@ describe('gog_drive_rename', () => {
182
183
  vi.mocked(runner.run).mockResolvedValue('{}');
183
184
  const harness = await setupHandlers();
184
185
  await harness.callTool('gog_drive_rename', { fileId: 'file1', newName: 'Budget 2026' });
185
- expect(runner.run).toHaveBeenCalledWith(['drive', 'rename', 'file1', 'Budget 2026'], { account: undefined });
186
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'rename', pos('file1'), pos('Budget 2026')], { account: undefined });
186
187
  });
187
188
 
188
189
  it('returns error text on failure', async () => {
@@ -198,7 +199,7 @@ describe('gog_drive_move', () => {
198
199
  vi.mocked(runner.run).mockResolvedValue('{}');
199
200
  const harness = await setupHandlers();
200
201
  await harness.callTool('gog_drive_move', { fileId: 'file1', parentId: 'folder1' });
201
- expect(runner.run).toHaveBeenCalledWith(['drive', 'move', 'file1', '--parent=folder1'], { account: undefined });
202
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'move', pos('file1'), '--parent=folder1'], { account: undefined });
202
203
  });
203
204
 
204
205
  it('returns error text on failure', async () => {
@@ -214,7 +215,7 @@ describe('gog_drive_delete', () => {
214
215
  vi.mocked(runner.run).mockResolvedValue('{}');
215
216
  const harness = await setupHandlers();
216
217
  await harness.callTool('gog_drive_delete', { fileId: 'file1' });
217
- expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'file1', '--force'], { account: undefined });
218
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('file1'), '--force'], { account: undefined });
218
219
  });
219
220
 
220
221
  it('appends --permanent when permanent=true', async () => {
@@ -222,7 +223,7 @@ describe('gog_drive_delete', () => {
222
223
  const harness = await setupHandlers();
223
224
  await harness.callTool('gog_drive_delete', { fileId: 'file1', permanent: true });
224
225
  expect(runner.run).toHaveBeenCalledWith(
225
- ['drive', 'delete', 'file1', '--permanent', '--force'],
226
+ ['drive', 'delete', pos('file1'), '--permanent', '--force'],
226
227
  { account: undefined },
227
228
  );
228
229
  });
@@ -231,7 +232,7 @@ describe('gog_drive_delete', () => {
231
232
  vi.mocked(runner.run).mockResolvedValue('{}');
232
233
  const harness = await setupHandlers();
233
234
  await harness.callTool('gog_drive_delete', { fileId: 'file1', permanent: false });
234
- expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'file1', '--force'], { account: undefined });
235
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('file1'), '--force'], { account: undefined });
235
236
  });
236
237
 
237
238
  it('returns error text on failure', async () => {
@@ -248,7 +249,7 @@ describe('gog_drive_share', () => {
248
249
  const harness = await setupHandlers();
249
250
  await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'user', email: 'bob@example.com' });
250
251
  expect(runner.run).toHaveBeenCalledWith(
251
- ['drive', 'share', 'file1', '--to=user', '--email=bob@example.com'],
252
+ ['drive', 'share', pos('file1'), '--to=user', '--email=bob@example.com'],
252
253
  { account: undefined },
253
254
  );
254
255
  });
@@ -258,7 +259,7 @@ describe('gog_drive_share', () => {
258
259
  const harness = await setupHandlers();
259
260
  await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'domain', domain: 'example.com', role: 'writer' });
260
261
  expect(runner.run).toHaveBeenCalledWith(
261
- ['drive', 'share', 'file1', '--to=domain', '--domain=example.com', '--role=writer'],
262
+ ['drive', 'share', pos('file1'), '--to=domain', '--domain=example.com', '--role=writer'],
262
263
  { account: undefined },
263
264
  );
264
265
  });
@@ -268,7 +269,7 @@ describe('gog_drive_share', () => {
268
269
  const harness = await setupHandlers();
269
270
  await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'anyone' });
270
271
  expect(runner.run).toHaveBeenCalledWith(
271
- ['drive', 'share', 'file1', '--to=anyone', '--force'],
272
+ ['drive', 'share', pos('file1'), '--to=anyone', '--force'],
272
273
  { account: undefined },
273
274
  );
274
275
  });
@@ -295,6 +296,58 @@ describe('gog_drive_run', () => {
295
296
  const result = await harness.callTool('gog_drive_run', { subcommand: 'copy', args: [] });
296
297
  expect(result.content[0].text).toBe('Error: Run failed');
297
298
  });
299
+
300
+ // SEC-1 regression: `gog --readonly drive mkdir x --readonly=false` reached
301
+ // Google on gog 0.41.0, defeating the GOG_READONLY kill switch.
302
+ it('refuses --readonly=false so GOG_READONLY cannot be overridden', async () => {
303
+ const harness = await setupHandlers();
304
+ const result = await harness.callTool('gog_drive_run', { subcommand: 'mkdir', args: ['x', '--readonly=false'] });
305
+ expect(result.isError).toBe(true);
306
+ expect(result.content[0].text).toMatch(/--readonly=false is not allowed/);
307
+ expect(runner.run).not.toHaveBeenCalled();
308
+ });
309
+ });
310
+
311
+ // SEC-3/SEC-4 through the escape hatch: GOG_FILE_ROOTS must bind gog_drive_run
312
+ // too, or `upload <gog's credentials.json>` exfiltrates what the roots protect.
313
+ describe('gog_drive_run path confinement', () => {
314
+ // The suite runs with GOG_FILE_ROOTS='/'; narrow it so confinement bites.
315
+ let prevRoots: string | undefined;
316
+ beforeEach(() => { prevRoots = process.env.GOG_FILE_ROOTS; process.env.GOG_FILE_ROOTS = '/nonexistent-gog-file-root'; });
317
+ afterEach(() => { process.env.GOG_FILE_ROOTS = prevRoots; });
318
+
319
+ it('refuses upload, whose local path is positional, pointing at gog_drive_upload', async () => {
320
+ const harness = await setupHandlers();
321
+ const result = await harness.callTool('gog_drive_run', {
322
+ subcommand: 'upload',
323
+ args: ['/Users/x/Library/Application Support/gogcli/credentials.json'],
324
+ });
325
+ expect(result.isError).toBe(true);
326
+ expect(result.content[0].text).toMatch(/gog_drive_upload/);
327
+ expect(runner.run).not.toHaveBeenCalled();
328
+ });
329
+
330
+ it('refuses a download --out outside GOG_FILE_ROOTS', async () => {
331
+ const harness = await setupHandlers();
332
+ const result = await harness.callTool('gog_drive_run', { subcommand: 'download', args: ['f1', '--out=~/.zshrc'] });
333
+ expect(result.isError).toBe(true);
334
+ expect(result.content[0].text).toMatch(/--out "~\/.zshrc" is outside/);
335
+ expect(runner.run).not.toHaveBeenCalled();
336
+ });
337
+
338
+ it('refuses a --on-change hook, which runs a local command', async () => {
339
+ const harness = await setupHandlers();
340
+ const result = await harness.callTool('gog_drive_run', { subcommand: 'changes', args: ['poll', '--on-change=sh -c id'] });
341
+ expect(result.isError).toBe(true);
342
+ expect(runner.run).not.toHaveBeenCalled();
343
+ });
344
+
345
+ it('still forwards a --file Drive ID, which is not a local path', async () => {
346
+ vi.mocked(runner.run).mockResolvedValue('{}');
347
+ const harness = await setupHandlers();
348
+ await harness.callTool('gog_drive_run', { subcommand: 'audit', args: ['sharing', '--file=1AbC'] });
349
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'audit', 'sharing', '--file=1AbC'], { account: undefined });
350
+ });
298
351
  });
299
352
 
300
353
  describe('gog_drive_extract_text', () => {
@@ -324,7 +377,7 @@ describe('gog_drive_extract_text', () => {
324
377
  ['api', 'call', 'drive', 'v3', 'files.export', '--params={"fileId":"tmpDoc1","mimeType":"text/plain"}'],
325
378
  { account: undefined },
326
379
  );
327
- expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc1', '--permanent', '--force'], { account: undefined });
380
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('tmpDoc1'), '--permanent', '--force'], { account: undefined });
328
381
  });
329
382
 
330
383
  it('passes an ocrLanguage hint on the copy', async () => {
@@ -340,7 +393,7 @@ describe('gog_drive_extract_text', () => {
340
393
  '--params={"fileId":"pdf1","ocrLanguage":"fr"}', '--body={"name":"gogcli-ocr-pdf1","mimeType":"application/vnd.google-apps.document"}'],
341
394
  { account: undefined, timeout: 300_000 },
342
395
  );
343
- expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc2', '--permanent', '--force'], { account: undefined });
396
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('tmpDoc2'), '--permanent', '--force'], { account: undefined });
344
397
  });
345
398
 
346
399
  it('exports a native Google Doc directly with no convert or temp cleanup', async () => {
@@ -383,7 +436,7 @@ describe('gog_drive_extract_text', () => {
383
436
  const result = await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1' });
384
437
  expect(result.isError).toBe(true);
385
438
  expect(result.content[0].text).toContain('export exploded');
386
- expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc3', '--permanent', '--force'], { account: undefined });
439
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('tmpDoc3'), '--permanent', '--force'], { account: undefined });
387
440
  });
388
441
 
389
442
  it('errors clearly when OCR conversion returns no id (and creates nothing to clean up)', async () => {
@@ -417,7 +470,7 @@ describe('gog_drive_extract_text', () => {
417
470
  .mockResolvedValueOnce('body');
418
471
  const harness = await setupHandlers();
419
472
  await harness.callTool('gog_drive_extract_text', { fileId: 'doc1', account: 'me@x.com' });
420
- expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'doc1'], { account: 'me@x.com' });
473
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'get', pos('doc1')], { account: 'me@x.com' });
421
474
  });
422
475
  });
423
476
 
@@ -435,10 +488,44 @@ describe('gog_drive_read_bytes', () => {
435
488
  expect(result.content[0].text).toContain('a.pdf');
436
489
  expect(runner.runBinary).toHaveBeenCalledWith(
437
490
  ['api', 'call', 'drive', 'v3', 'files.get', '--params={"fileId":"f1","alt":"media"}'],
438
- { account: undefined },
491
+ { account: undefined, maxOutputBytes: 8 * 1024 * 1024 },
439
492
  );
440
493
  });
441
494
 
495
+ // BUG-2: the size is known from `drive get` before a byte is fetched.
496
+ it('refuses a file over the inline ceiling without fetching its bytes', async () => {
497
+ vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'backup.zip', mimeType: 'application/zip', size: '4294967296' } }));
498
+ const harness = await setupHandlers();
499
+ const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
500
+ expect(result.isError).toBe(true);
501
+ expect(result.content[0].text).toMatch(/4294967296 bytes.*8 MiB/);
502
+ expect(result.content[0].text).toContain('gog_drive_extract_text');
503
+ expect(runner.runBinary).not.toHaveBeenCalled();
504
+ });
505
+
506
+ it('names the file by id when an oversized file has no name', async () => {
507
+ vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ size: 9 * 1024 * 1024 }));
508
+ const harness = await setupHandlers();
509
+ const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f9' });
510
+ expect(result.content[0].text).toMatch(/^f9 is 9437184 bytes/);
511
+ });
512
+
513
+ it('treats an unparseable size as unknown and relies on the fetch cap', async () => {
514
+ vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.bin', size: 'n/a' } }));
515
+ vi.mocked(runner.runBinary).mockResolvedValueOnce(Buffer.from('x').toString('base64'));
516
+ const harness = await setupHandlers();
517
+ const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
518
+ expect(result.isError).toBeFalsy();
519
+ });
520
+
521
+ it('caps the fetch itself when Drive reports no size', async () => {
522
+ vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.bin', mimeType: 'application/octet-stream' } }));
523
+ vi.mocked(runner.runBinary).mockResolvedValueOnce(Buffer.from('x').toString('base64'));
524
+ const harness = await setupHandlers();
525
+ await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
526
+ expect(runner.runBinary).toHaveBeenCalledWith(expect.any(Array), { account: undefined, maxOutputBytes: 8 * 1024 * 1024 });
527
+ });
528
+
442
529
  it('names the text fallback when the resource type is one hosts refuse to render', async () => {
443
530
  // The bytes arrive fine; some hosts just drop a non-image embedded resource
444
531
  // ("Resources of type 'application/pdf' are not currently supported"), and