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.
@@ -19,13 +19,13 @@ describe('gog_docs_info', () => {
19
19
  expect(result.content[0].text).toContain('My Doc');
20
20
  });
21
21
 
22
- it('appends auth list on failure when auth list succeeds', async () => {
22
+ it('appends the account list only on an auth failure', async () => {
23
23
  vi.mocked(runner.run)
24
- .mockRejectedValueOnce(new Error('Doc not found'))
25
- .mockResolvedValueOnce('user@gmail.com');
24
+ .mockRejectedValueOnce(new Error('Doc not found'));
26
25
  const harness = await setupHandlers();
27
26
  const result = await harness.callTool('gog_docs_info', { docId: 'bad' });
28
- expect(result.content[0].text).toBe('Error: Doc not found\n\nConfigured accounts:\nuser@gmail.com');
27
+ expect(result.content[0].text).toBe('Error: Doc not found');
28
+ expect(runner.run).toHaveBeenCalledTimes(1);
29
29
  });
30
30
 
31
31
  it('returns plain error text when auth list also fails', async () => {
@@ -37,8 +37,7 @@ describe('gog_docs_info', () => {
37
37
 
38
38
  it('handles non-Error rejection', async () => {
39
39
  vi.mocked(runner.run)
40
- .mockRejectedValueOnce('raw error string')
41
- .mockRejectedValueOnce(new Error('auth list failed'));
40
+ .mockRejectedValueOnce('raw error string');
42
41
  const harness = await setupHandlers();
43
42
  const result = await harness.callTool('gog_docs_info', { docId: 'bad' });
44
43
  expect(result.content[0].text).toBe('raw error string');
@@ -433,7 +433,6 @@ describe('gog_drive_extract_text', () => {
433
433
  .mockResolvedValueOnce(PDF_META) // drive get
434
434
  .mockResolvedValueOnce(JSON.stringify({ id: 'tmpDoc3' })) // files.copy
435
435
  .mockRejectedValueOnce(new Error('export exploded')) // files.export fails
436
- .mockResolvedValueOnce('user@x.com') // diagnose -> auth list
437
436
  .mockResolvedValueOnce('deleted'); // cleanup delete
438
437
  const harness = await setupHandlers();
439
438
  const result = await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1' });
@@ -445,8 +444,7 @@ describe('gog_drive_extract_text', () => {
445
444
  it('errors clearly when OCR conversion returns no id (and creates nothing to clean up)', async () => {
446
445
  vi.mocked(runner.run)
447
446
  .mockResolvedValueOnce(PDF_META) // drive get
448
- .mockResolvedValueOnce('{}') // files.copy: no id
449
- .mockResolvedValueOnce('user@x.com'); // diagnose -> auth list
447
+ .mockResolvedValueOnce('{}'); // files.copy: no id
450
448
  const harness = await setupHandlers();
451
449
  const result = await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1' });
452
450
  expect(result.isError).toBe(true);
@@ -560,8 +558,7 @@ describe('gog_drive_read_bytes', () => {
560
558
 
561
559
  it('surfaces a byte-fetch failure via diagnose', async () => {
562
560
  vi.mocked(runner.run)
563
- .mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.pdf', mimeType: 'application/pdf' } }))
564
- .mockResolvedValueOnce('user@x.com'); // diagnose -> auth list
561
+ .mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.pdf', mimeType: 'application/pdf' } }));
565
562
  vi.mocked(runner.runBinary).mockRejectedValueOnce(new Error('gog exited with code 1'));
566
563
  const harness = await setupHandlers();
567
564
  const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
@@ -23,9 +23,9 @@ let fileRoot: string;
23
23
  beforeEach(() => {
24
24
  vi.clearAllMocks();
25
25
  process.env = { ...ORIGINAL_ENV };
26
- delete process.env.GOG_SEND_CONFIRM_FALLBACK;
27
- delete process.env.GOG_CONFIRM_TTL_SECONDS;
28
- delete process.env.GOG_CONFIRM_SECRET;
26
+ delete process.env.MCP_CONFIRM_MODE;
27
+ delete process.env.MCP_CONFIRM_TTL_SECONDS;
28
+ delete process.env.MCP_CONFIRM_SECRET;
29
29
  process.env.GOG_ACCOUNT = 'me@example.com';
30
30
  fileRoot = mkdtempSync(join(tmpdir(), 'gct-roots-'));
31
31
  process.env.GOG_FILE_ROOTS = fileRoot;
@@ -58,24 +58,26 @@ const sendCalls = () => vi.mocked(runner.run).mock.calls.filter(([args]) => args
58
58
 
59
59
  describe('gog_gmail_send — token fallback', () => {
60
60
  it('elicitation supported: unchanged — prompts and sends, confirmToken or not', async () => {
61
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
61
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
62
62
  vi.mocked(runner.run).mockResolvedValue(SENT);
63
63
  const harness = await withElicitation();
64
- const result = await harness.callTool('gog_gmail_send', { ...SEND_ARGS, confirmToken: 'gct1.not.real' });
64
+ const result = await harness.callTool('gog_gmail_send', { ...SEND_ARGS, confirmToken: 'not-a-real-token' });
65
65
  expect(result.content[0]!.text).toBe(SENT);
66
66
  expect(sendCalls()).toHaveLength(1);
67
67
  });
68
68
 
69
- it('unsupported + env unset: refuses, with the new hint', async () => {
69
+ it('unsupported + MCP_CONFIRM_MODE=refuse: refuses, naming the switch', async () => {
70
+
71
+ process.env.MCP_CONFIRM_MODE = 'refuse';
70
72
  const harness = await noElicitation();
71
73
  const body = json(await harness.callTool('gog_gmail_send', SEND_ARGS));
72
74
  expect(body).toMatchObject({ confirmed: false, dispatched: false, reason: 'confirmation-unsupported' });
73
- expect(body.note).toContain('set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation');
75
+ expect(body.note).toContain('Set MCP_CONFIRM_MODE=ask-user');
74
76
  expect(runner.run).not.toHaveBeenCalled();
75
77
  });
76
78
 
77
- describe('unsupported + GOG_SEND_CONFIRM_FALLBACK=token', () => {
78
- beforeEach(() => { process.env.GOG_SEND_CONFIRM_FALLBACK = 'token'; });
79
+ describe('unsupported + MCP_CONFIRM_MODE=ask-user', () => {
80
+ beforeEach(() => { process.env.MCP_CONFIRM_MODE = 'ask-user'; });
79
81
 
80
82
  it('phase 1 previews everything (Bcc included, full body) and sends nothing', async () => {
81
83
  const long = `${'x'.repeat(5000)}END`;
@@ -101,7 +103,7 @@ describe('gog_gmail_send — token fallback', () => {
101
103
  inReplyTo: 'm0',
102
104
  quotesOriginal: false,
103
105
  });
104
- expect(body.confirmToken).toMatch(/^gct1\./);
106
+ expect(body.confirmToken).toMatch(/^mcpu\.token\.v1\./);
105
107
  expect(body.instruction).toMatch(/verbatim/);
106
108
  expect(runner.run).not.toHaveBeenCalled();
107
109
  });
@@ -185,7 +187,7 @@ describe.each([
185
187
  });
186
188
  const replyCalls = () => vi.mocked(runner.run).mock.calls.filter(([args]) => args[1] === subcommand);
187
189
 
188
- beforeEach(() => { process.env.GOG_SEND_CONFIRM_FALLBACK = 'token'; });
190
+ beforeEach(() => { process.env.MCP_CONFIRM_MODE = 'ask-user'; });
189
191
 
190
192
  it('phase 1 previews the resolved recipients, Bcc, full body and In-Reply-To; nothing sent', async () => {
191
193
  vi.mocked(runner.run).mockResolvedValue(metadata());
@@ -0,0 +1,269 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createTestHarness } from '@chrischall/mcp-utils/test';
3
+ import * as runner from '../../src/runner.js';
4
+ import { registerChatTools, vetChatRun } from '../../src/tools/chat.js';
5
+ import { registerDriveTools, vetDriveRun } from '../../src/tools/drive.js';
6
+ import { registerClassroomTools, vetClassroomRun } from '../../src/tools/classroom.js';
7
+ import { registerCalendarTools, vetCalendarRun } from '../../src/tools/calendar.js';
8
+ import { registerGmailTools, vetGmailRun } from '../../src/tools/gmail.js';
9
+ import { registerDocsTools, vetDocsRun } from '../../src/tools/docs.js';
10
+ import { registerSheetsTools, vetSheetsRun } from '../../src/tools/sheets.js';
11
+ import { registerAppScriptTools, vetAppScriptRun } from '../../src/tools/appscript.js';
12
+ import { refusedApiCall } from '../../src/tools/api.js';
13
+
14
+ vi.mock('../../src/runner.js');
15
+
16
+ // ============================================================================
17
+ // The second wave of gates (fleet audit 2026-09-24 SEC-6) is refused by the
18
+ // escape hatches under every alias gog 0.41.0 accepts for it, so
19
+ // gog_<service>_run is not a way around a prompt the dedicated tool shows.
20
+ // ============================================================================
21
+
22
+ beforeEach(() => vi.clearAllMocks());
23
+
24
+ describe('vetCalendarRun — move, out-of-office, delete, delete-calendar', () => {
25
+ it.each([
26
+ ['move', 'gog_calendar_move'],
27
+ ['transfer', 'gog_calendar_move'],
28
+ ['out-of-office', 'gog_calendar_out_of_office'],
29
+ ['OOO', 'gog_calendar_out_of_office'],
30
+ ['delete', 'gog_calendar_delete'],
31
+ ['del', 'gog_calendar_delete'],
32
+ ['remove', 'gog_calendar_delete'],
33
+ ['rm', 'gog_calendar_delete'],
34
+ ['delete-calendar', 'gog_calendar_delete_calendar'],
35
+ ])('refuses %s in favour of %s', (sub, tool) => {
36
+ expect(vetCalendarRun(sub, ['primary', 'e1'])).toMatch(new RegExp(`${tool}.*asks the user`));
37
+ });
38
+
39
+ it.each(['events', 'freebusy', 'calendars', 'unsubscribe', 'event'])('still allows %s', (sub) => {
40
+ expect(vetCalendarRun(sub, ['primary'])).toBeUndefined();
41
+ });
42
+ });
43
+
44
+ describe('vetClassroomRun — roster, coursework, submissions and course deletes', () => {
45
+ it.each([
46
+ ['students', ['add', 'c1', 'u1'], 'gog_classroom_students_add'],
47
+ ['student', ['create', 'c1', 'u1'], 'gog_classroom_students_add'],
48
+ ['students', ['NEW', 'c1', 'u1'], 'gog_classroom_students_add'],
49
+ ['teachers', ['add', 'c1', 'u1'], 'gog_classroom_teachers_add'],
50
+ ['teacher', ['new', 'c1', 'u1'], 'gog_classroom_teachers_add'],
51
+ ['submissions', ['return', 'c1', 'w1', 's1'], 'gog_classroom_submissions_return'],
52
+ ['submission', ['send', 'c1', 'w1', 's1'], 'gog_classroom_submissions_return'],
53
+ ['coursework', ['create', 'c1', '--title=HW'], 'gog_classroom_coursework_create'],
54
+ ['work', ['add', 'c1'], 'gog_classroom_coursework_create'],
55
+ ['coursework', ['delete', 'c1', 'w1'], 'gog_classroom_coursework_delete'],
56
+ ['work', ['rm', 'c1', 'w1'], 'gog_classroom_coursework_delete'],
57
+ ['courses', ['delete', 'c1'], 'gog_classroom_courses_delete'],
58
+ ['course', ['remove', 'c1'], 'gog_classroom_courses_delete'],
59
+ ['courses', ['--color', 'never', 'del', 'c1'], 'gog_classroom_courses_delete'],
60
+ ])('refuses %s %j in favour of %s', (sub, args, tool) => {
61
+ expect(vetClassroomRun(sub, args)).toMatch(new RegExp(`${tool}.*asks the user`));
62
+ });
63
+
64
+ it.each([
65
+ ['students', ['list', 'c1']],
66
+ ['students', ['remove', 'c1', 'u1']],
67
+ ['students', ['delete', 'c1', 'u1']],
68
+ ['teachers', ['remove', 'c1', 'u1']],
69
+ ['submissions', ['grade', 'c1', 'w1', 's1', '--assigned=90']],
70
+ ['submissions', ['list', 'c1', 'w1']],
71
+ ['coursework', ['update', 'c1', 'w1', '--title=x']],
72
+ ['coursework', ['list', 'c1']],
73
+ ['courses', ['create', '--name=x']],
74
+ ['courses', ['archive', 'c1']],
75
+ ['announcements', ['create', 'c1']],
76
+ ])('allows %s %j (or leaves it to the existing vet)', (sub, args) => {
77
+ const refusal = vetClassroomRun(sub, args);
78
+ if (sub === 'announcements') expect(refusal).toMatch(/gog_classroom_announcements_create/);
79
+ else expect(refusal).toBeUndefined();
80
+ });
81
+ });
82
+
83
+ describe('vetDriveRun — comments and permanent deletes', () => {
84
+ it.each([
85
+ ['comments', ['create', 'f1', 'text'], 'gog_drive_comments_add'],
86
+ ['comments', ['add', 'f1', 'text'], 'gog_drive_comments_add'],
87
+ ['comments', ['new', 'f1', 'text'], 'gog_drive_comments_add'],
88
+ ['comments', ['reply', 'f1', 'c1', 'text'], 'gog_drive_comments_reply'],
89
+ ['comments', ['respond', 'f1', 'c1', 'text'], 'gog_drive_comments_reply'],
90
+ ['delete', ['f1', '--permanent'], 'gog_drive_delete'],
91
+ ['del', ['f1', '--permanent=true', '--force'], 'gog_drive_delete'],
92
+ ['rm', ['--PERMANENT', 'f1'], 'gog_drive_delete'],
93
+ ['share', ['f1', '--to=anyone'], 'gog_drive_share'],
94
+ ])('refuses %s %j in favour of %s', (sub, args, tool) => {
95
+ expect(vetDriveRun(sub, args)).toMatch(new RegExp(`${tool}.*asks the user`));
96
+ });
97
+
98
+ it.each([
99
+ ['comments', ['list', 'f1']],
100
+ ['comments', ['resolve', 'f1', 'c1']],
101
+ ['comments', ['update', 'f1', 'c1', 'text']],
102
+ ['delete', ['f1']],
103
+ ['delete', ['f1', '--force']],
104
+ ['copy', ['f1']],
105
+ ])('allows %s %j', (sub, args) => {
106
+ expect(vetDriveRun(sub, args)).toBeUndefined();
107
+ });
108
+ });
109
+
110
+ describe('vetChatRun — spaces created with members', () => {
111
+ it.each([
112
+ ['spaces', ['create', 'Launch', '--member=a@example.com']],
113
+ ['spaces', ['add', 'Launch', '--member', 'a@example.com']],
114
+ ['SPACES', ['new', 'Launch', '--members=a@example.com,b@example.com']],
115
+ ])('refuses %s %j', (sub, args) => {
116
+ expect(vetChatRun(sub, args)).toMatch(/gog_chat_spaces_create.*asks the user/);
117
+ });
118
+
119
+ it.each([
120
+ ['spaces', ['create', 'Launch']],
121
+ ['spaces', ['list']],
122
+ ['spaces', ['find', 'launch']],
123
+ ['messages', ['list', 'spaces/A']],
124
+ ['threads', ['list', 'spaces/A']],
125
+ ])('allows %s %j', (sub, args) => {
126
+ expect(vetChatRun(sub, args)).toBeUndefined();
127
+ });
128
+ });
129
+
130
+ describe('vetGmailRun — batch delete, send-as aliases, vacation responder', () => {
131
+ it.each([
132
+ ['batch', ['delete', 'm1', 'm2'], 'gog_gmail_batch_delete'],
133
+ ['batch', ['del', 'm1', '--force'], 'gog_gmail_batch_delete'],
134
+ ['batch', ['remove', 'm1'], 'gog_gmail_batch_delete'],
135
+ ['batch', ['rm', 'm1'], 'gog_gmail_batch_delete'],
136
+ ['settings', ['sendas', 'create', 'x@example.com'], 'gog_gmail_sendas_create'],
137
+ ['settings', ['sendas', 'add', 'x@example.com'], 'gog_gmail_sendas_create'],
138
+ ['sendas', ['new', 'x@example.com'], 'gog_gmail_sendas_create'],
139
+ ['settings', ['vacation', 'update', '--enable', '--subject=Away'], 'gog_gmail_vacation_update'],
140
+ ['settings', ['vacation', 'edit', '--enable=true'], 'gog_gmail_vacation_update'],
141
+ ['vacation', ['set', '--enable'], 'gog_gmail_vacation_update'],
142
+ ])('refuses %s %j in favour of %s', (sub, args, tool) => {
143
+ expect(vetGmailRun(sub, args)).toMatch(new RegExp(`${tool}.*asks the user`));
144
+ });
145
+
146
+ it.each([
147
+ ['batch', ['modify', 'm1', '--add=Label']],
148
+ ['batch', ['trash', 'm1']],
149
+ ['settings', ['sendas', 'list']],
150
+ ['sendas', ['get', 'x@example.com']],
151
+ ['settings', ['vacation', 'get']],
152
+ ['settings', ['vacation', 'update', '--disable']],
153
+ ['vacation', ['update', '--subject=Away']],
154
+ ['archive', ['m1']],
155
+ ])('allows %s %j', (sub, args) => {
156
+ expect(vetGmailRun(sub, args)).toBeUndefined();
157
+ });
158
+
159
+ it('still refuses the settings that route mail elsewhere', () => {
160
+ expect(vetGmailRun('filters', ['create'])).toMatch(/gog_gmail_\*/);
161
+ expect(vetGmailRun('settings', ['forwarding', 'add'])).toMatch(/gog_gmail_\*/);
162
+ expect(vetGmailRun('autoreply', [])).toMatch(/gog_gmail_autoreply/);
163
+ });
164
+ });
165
+
166
+ describe('vetDocsRun — comments', () => {
167
+ it.each([
168
+ ['comments', ['add', 'd1', 'text'], 'gog_docs_comments_add'],
169
+ ['comments', ['create', 'd1', 'text'], 'gog_docs_comments_add'],
170
+ ['comments', ['new', 'd1', 'text'], 'gog_docs_comments_add'],
171
+ ['comments', ['reply', 'd1', 'c1', 'text'], 'gog_docs_comments_reply'],
172
+ ['COMMENTS', ['respond', 'd1', 'c1', 'text'], 'gog_docs_comments_reply'],
173
+ ])('refuses %s %j in favour of %s', (sub, args, tool) => {
174
+ expect(vetDocsRun(sub, args)).toMatch(new RegExp(`${tool}.*asks the user`));
175
+ });
176
+
177
+ it.each([
178
+ ['comments', ['list', 'd1']],
179
+ ['comments', ['resolve', 'd1', 'c1']],
180
+ ['copy', ['d1']],
181
+ ['sed', ['d1', 's/a/b/']],
182
+ ])('allows %s %j', (sub, args) => {
183
+ expect(vetDocsRun(sub, args)).toBeUndefined();
184
+ });
185
+ });
186
+
187
+ describe('vetSheetsRun — billed Connected Sheets executions', () => {
188
+ it.each([
189
+ ['datasource', ['add', 'sid', '--billing-project=p'], 'gog_sheets_datasource_add'],
190
+ ['data-source', ['add', 'sid'], 'gog_sheets_datasource_add'],
191
+ ['datasource', ['update', 'sid', 'ds1', '--query=SELECT 1'], 'gog_sheets_datasource_update'],
192
+ ['data-sources', ['update', 'sid', 'ds1'], 'gog_sheets_datasource_update'],
193
+ ['datasource', ['refresh', 'sid', 'ds1'], 'gog_sheets_datasource_refresh'],
194
+ ['connected-sheets', ['REFRESH', 'sid', 'ds1'], 'gog_sheets_datasource_refresh'],
195
+ ])('refuses %s %j in favour of %s', (sub, args, tool) => {
196
+ expect(vetSheetsRun(sub, args)).toMatch(new RegExp(`${tool}.*asks the user`));
197
+ });
198
+
199
+ it.each([
200
+ ['datasource', ['list', 'sid']],
201
+ ['datasource', ['describe', 'sid', 'ds1']],
202
+ ['datasource', ['delete', 'sid', 'ds1']],
203
+ ['datasource', ['table', 'read', 'sid', 'A1']],
204
+ ['freeze', ['sid', '--rows=1']],
205
+ ['add-tab', ['sid', 'Data']],
206
+ ])('allows %s %j', (sub, args) => {
207
+ expect(vetSheetsRun(sub, args)).toBeUndefined();
208
+ });
209
+ });
210
+
211
+ describe('vetAppScriptRun — running code', () => {
212
+ it.each(['run', 'RUN'])('refuses %s', (sub) => {
213
+ expect(vetAppScriptRun(sub, ['S1', 'doWork'])).toMatch(/gog_appscript_run_function.*asks the user/);
214
+ });
215
+
216
+ it.each(['get', 'content', 'deployments', 'versions', 'create'])('allows %s', (sub) => {
217
+ expect(vetAppScriptRun(sub, ['S1'])).toBeUndefined();
218
+ });
219
+ });
220
+
221
+ describe('the vets are wired into the run tools', () => {
222
+ it.each([
223
+ [registerCalendarTools, 'gog_calendar_run', { subcommand: 'move', args: ['primary', 'e1', 'other', '--send-updates=all'] }],
224
+ [registerClassroomTools, 'gog_classroom_run', { subcommand: 'students', args: ['add', 'c1', 'u1'] }],
225
+ [registerDriveTools, 'gog_drive_run', { subcommand: 'comments', args: ['create', 'f1', 'hi'] }],
226
+ [registerChatTools, 'gog_chat_run', { subcommand: 'spaces', args: ['create', 'Launch', '--member=a@example.com'] }],
227
+ [registerGmailTools, 'gog_gmail_run', { subcommand: 'batch', args: ['delete', 'm1'] }],
228
+ [registerDocsTools, 'gog_docs_run', { subcommand: 'comments', args: ['add', 'd1', 'hi'] }],
229
+ [registerSheetsTools, 'gog_sheets_run', { subcommand: 'datasource', args: ['refresh', 'sid', 'ds1'] }],
230
+ [registerAppScriptTools, 'gog_appscript_run', { subcommand: 'run', args: ['S1', 'doWork'] }],
231
+ ] as const)('%#: %s refuses without spawning gog', async (register, tool, args) => {
232
+ const harness = await createTestHarness(register);
233
+ const result = await harness.callTool(tool, args);
234
+ expect(result.isError).toBe(true);
235
+ expect(result.content[0]!.text).toMatch(/asks the user to confirm/);
236
+ expect(runner.run).not.toHaveBeenCalled();
237
+ });
238
+ });
239
+
240
+ describe('refusedApiCall — the second wave', () => {
241
+ it.each([
242
+ ['chat', 'spaces.setup', 'gog_chat_spaces_create'],
243
+ ['chat', 'chat.spaces.members.create', 'gog_chat_spaces_create'],
244
+ ['drive', 'comments.create', 'gog_drive_comments_add'],
245
+ ['drive', 'replies.create', 'gog_drive_comments_reply'],
246
+ ['classroom', 'courses.students.create', 'gog_classroom_students_add'],
247
+ ['classroom', 'courses.teachers.create', 'gog_classroom_teachers_add'],
248
+ ['classroom', 'courses.courseWork.create', 'gog_classroom_coursework_create'],
249
+ ['classroom', 'courses.courseWork.studentSubmissions.return', 'gog_classroom_submissions_return'],
250
+ ['calendar', 'events.move', 'gog_calendar_move'],
251
+ ['gmail', 'users.settings.sendAs.create', 'gog_gmail_sendas_create'],
252
+ ['gmail', 'users.settings.updateVacation', 'gog_gmail_vacation_update'],
253
+ ['script', 'scripts.run', 'gog_appscript_run_function'],
254
+ ])('refuses %s %s', (api, method, tool) => {
255
+ expect(refusedApiCall(api, method)).toContain(tool);
256
+ });
257
+
258
+ it.each([
259
+ ['chat', 'spaces.members.list'],
260
+ ['drive', 'comments.list'],
261
+ ['classroom', 'courses.students.list'],
262
+ ['classroom', 'courses.courseWork.studentSubmissions.patch'],
263
+ ['gmail', 'users.settings.getVacation'],
264
+ ['gmail', 'users.settings.sendAs.list'],
265
+ ['script', 'projects.get'],
266
+ ])('allows %s %s', (api, method) => {
267
+ expect(refusedApiCall(api, method)).toBeUndefined();
268
+ });
269
+ });
@@ -44,7 +44,30 @@ describe('vetDriveRun', () => {
44
44
  expect(vetDriveRun(sub, ['f1', '--to=anyone'])).toMatch(/gog_drive_share.*asks the user/);
45
45
  });
46
46
 
47
- it.each(['permissions', 'unshare', 'copy'])('allows %s', (sub) => {
47
+ // SEC-1 (fleet-audit #930): `drive bulk update-role --from=reader --to=writer`
48
+ // rewrites every matching permission across a tree with no positional path,
49
+ // so neither the share vet nor the path guard saw it.
50
+ it.each([
51
+ ['bulk', ['update-role', '--from=reader', '--to=writer', '--depth=0', '--force']],
52
+ ['BULK', ['remove-public', '--parent=folder1']],
53
+ ['bulk', []],
54
+ ])('refuses %s %j', (sub, args) => {
55
+ const refusal = vetDriveRun(sub, args);
56
+ expect(refusal).toMatch(/gog drive bulk/);
57
+ expect(refusal).toMatch(/every matching file/);
58
+ expect(refusal).toMatch(/gog_drive_share/);
59
+ });
60
+
61
+ // `unshare` removes a collaborator's access with no preview of who: route it
62
+ // through the dedicated tool, whose call the host shows structured.
63
+ it('refuses unshare in favour of gog_drive_unshare', () => {
64
+ const refusal = vetDriveRun('Unshare', ['f1', 'p1']);
65
+ expect(refusal).toMatch(/not available through gog_drive_run/);
66
+ expect(refusal).toMatch(/gog_drive_unshare/);
67
+ });
68
+
69
+ // `permissions <fileId>` only lists (gog 0.41.0 has no mutating form).
70
+ it.each(['permissions', 'copy'])('allows %s', (sub) => {
48
71
  expect(vetDriveRun(sub, ['f1'])).toBeUndefined();
49
72
  });
50
73
  });
@@ -61,10 +84,91 @@ describe('vetClassroomRun', () => {
61
84
  expect(vetClassroomRun(sub, args)).toMatch(/gog_classroom_(announcements|invitations)_create.*asks the user/);
62
85
  });
63
86
 
87
+ // SEC-3 (fleet-audit #932): create-as-DRAFT then update-to-PUBLISHED was the
88
+ // same two-step around the create gate that closed for Gmail drafts.
89
+ it.each([
90
+ ['announcements', ['update', 'c1', 'a1', '--state=PUBLISHED']],
91
+ ['announcement', ['edit', 'c1', 'a1', '--state', 'published']],
92
+ ['ann', ['SET', 'c1', 'a1', '--scheduled=2026-10-01T12:00:00Z']],
93
+ ['ann', ['set', 'c1', 'a1', '--state=DRAFT', '--scheduled=2026-10-01T12:00:00Z']],
94
+ ])('refuses publishing an announcement via %s %j', (sub, args) => {
95
+ expect(vetClassroomRun(sub, args)).toMatch(/gog_classroom_announcements_update.*asks the user/);
96
+ });
97
+
98
+ it.each([
99
+ ['coursework', ['update', 'c1', 'w1', '--state=PUBLISHED']],
100
+ ['work', ['edit', 'c1', 'w1', '--scheduled=2026-10-01T12:00:00Z']],
101
+ ])('refuses publishing coursework via %s %j', (sub, args) => {
102
+ expect(vetClassroomRun(sub, args)).toMatch(/gog_classroom_coursework_update.*asks the user/);
103
+ });
104
+
105
+ // Materials have no dedicated update tool, so publishing one through
106
+ // update/edit/set was the create-as-DRAFT then update-to-PUBLISHED route.
107
+ it.each([
108
+ ['materials', ['update', 'c1', 'm1', '--state=PUBLISHED']],
109
+ ['materials', ['update', 'c1', 'm1', '--state', 'PUBLISHED']],
110
+ ['material', ['edit', 'c1', 'm1', '--state=published']],
111
+ ['material', ['SET', 'c1', 'm1', '--state', 'Published']],
112
+ ['materials', ['set', 'c1', 'm1', '--scheduled=2026-10-01T12:00:00Z']],
113
+ ['material', ['update', 'c1', 'm1', '--state=DRAFT', '--scheduled', '2026-10-01T12:00:00Z']],
114
+ ])('refuses publishing material via %s %j', (sub, args) => {
115
+ const refusal = vetClassroomRun(sub, args);
116
+ expect(refusal).toMatch(/publishes material to students and is not available through gog_classroom_run/);
117
+ expect(refusal).toMatch(/Ask the user to publish it from Classroom/);
118
+ });
119
+
120
+ // Assigning a published item to more students widens who sees it.
121
+ it.each([
122
+ ['announcements', ['assignees', 'c1', 'a1', '--mode=ALL_STUDENTS']],
123
+ ['ann', ['assign', 'c1', 'a1', '--add-student=s1,s2']],
124
+ ['coursework', ['assignees', 'c1', 'w1', '--mode', 'all_students']],
125
+ ['work', ['ASSIGN', 'c1', 'w1', '--add-student', 's1']],
126
+ ])('refuses widening assignees via %s %j', (sub, args) => {
127
+ const refusal = vetClassroomRun(sub, args);
128
+ expect(refusal).toMatch(/shows it to more students and is not available through gog_classroom_run/);
129
+ expect(refusal).toMatch(/from Classroom/);
130
+ });
131
+
132
+ it.each([
133
+ ['coursework', ['create', 'c1', '--title=HW'], /gog_classroom_coursework_create/],
134
+ ['work', ['add', 'c1', '--title=HW', '--state=PUBLISHED'], /gog_classroom_coursework_create/],
135
+ ['coursework', ['new', 'c1', '--title=HW', '--state=DRAFT', '--scheduled=2026-10-01T12:00:00Z'], /gog_classroom_coursework_create/],
136
+ ['materials', ['create', 'c1', '--title=Notes'], /--state=DRAFT/],
137
+ ['material', ['add', 'c1', '--title=Notes', '--state=DRAFT', '--scheduled=2026-10-01T12:00:00Z'], /--state=DRAFT/],
138
+ ['students', ['add', 'c1', 'u1'], /gog_classroom_students_add/],
139
+ ['student', ['create', 'c1', 'u1'], /gog_classroom_students_add/],
140
+ ['teachers', ['new', 'c1', 'u1'], /gog_classroom_teachers_add/],
141
+ ['teacher', ['add', 'c1', 'u1'], /gog_classroom_teachers_add/],
142
+ ['submissions', ['return', 'c1', 'w1', 's1'], /gog_classroom_submissions_return/],
143
+ ['submission', ['send', 'c1', 'w1', 's1'], /gog_classroom_submissions_return/],
144
+ ['guardian-invitations', ['create', 'student1', '--email=parent@example.com'], /from Classroom/],
145
+ ['guardian-invites', ['add', 'student1', '--email=parent@example.com'], /from Classroom/],
146
+ ])('refuses %s %j', (sub, args, instead) => {
147
+ const refusal = vetClassroomRun(sub, args);
148
+ expect(refusal).toMatch(/not available through gog_classroom_run/);
149
+ expect(refusal).toMatch(instead);
150
+ });
151
+
64
152
  it.each([
65
153
  ['announcements', ['list', 'c1']],
66
- ['invitations', ['accept', 'i1']],
154
+ ['announcements', ['update', 'c1', 'a1', '--text=fixed typo']],
155
+ ['announcements', ['update', 'c1', 'a1', '--state=DRAFT']],
156
+ ['coursework', ['update', 'c1', 'w1', '--title=Renamed']],
157
+ ['coursework', ['create', 'c1', '--title=HW', '--state=DRAFT']],
158
+ ['materials', ['create', 'c1', '--title=Notes', '--state=draft']],
159
+ ['materials', ['list', 'c1']],
160
+ ['materials', ['update', 'c1', 'm1', '--state=DRAFT']],
161
+ ['material', ['edit', 'c1', 'm1', '--title=Renamed']],
162
+ ['announcements', ['assignees', 'c1', 'a1', '--remove-student=s1']],
163
+ ['coursework', ['assign', 'c1', 'w1', '--mode=INDIVIDUAL_STUDENTS', '--remove-student', 's1']],
164
+ ['students', ['list', 'c1']],
165
+ ['students', ['remove', 'c1', 'u1', '--force']],
166
+ ['teachers', ['get', 'c1', 'u1']],
167
+ ['submissions', ['grade', 'c1', 'w1', 's1', '--draft=90']],
168
+ ['submissions', ['turn-in', 'c1', 'w1', 's1']],
169
+ ['guardian-invitations', ['list', 'student1']],
67
170
  ['guardians', ['create', 'c1']],
171
+ ['invitations', ['accept', 'i1']],
68
172
  ['courses', ['create']],
69
173
  ])('allows %s %j', (sub, args) => {
70
174
  expect(vetClassroomRun(sub, args)).toBeUndefined();
@@ -76,7 +180,49 @@ describe('vetCalendarRun', () => {
76
180
  expect(vetCalendarRun(sub, ['primary'])).toMatch(/gog_calendar_(create|update|respond).*asks the user/);
77
181
  });
78
182
 
79
- it.each(['events', 'freebusy', 'calendars', 'create-calendar', 'focus-time'])('allows %s', (sub) => {
183
+ // SEC-4 (fleet-audit #933): `propose-time --decline` (and --comment, which
184
+ // implies it) declines the event and notifies the organizer, exactly what
185
+ // gog_calendar_respond asks about.
186
+ it.each([
187
+ ['propose-time', ['primary', 'e1', '--decline']],
188
+ ['PROPOSE-TIME', ['primary', 'e1', '--decline=true']],
189
+ ['propose-time', ['primary', 'e1', '--comment=Can we do Tuesday?']],
190
+ ['propose-time', ['primary', 'e1', '--comment', 'Tuesday?']],
191
+ ])('refuses %s %j', (sub, args) => {
192
+ expect(vetCalendarRun(sub, args)).toMatch(/gog_calendar_respond.*asks the user/);
193
+ });
194
+
195
+ it('allows propose-time when it only generates the URL', () => {
196
+ expect(vetCalendarRun('propose-time', ['primary', 'e1'])).toBeUndefined();
197
+ });
198
+
199
+ // The run tool cannot see an event's guests, so the rest of the guest-visible
200
+ // subcommands are refused outright in favour of their dedicated tools.
201
+ it.each([
202
+ ['move', ['primary', 'e1', 'other'], /gog_calendar_move/],
203
+ ['transfer', ['primary', 'e1', 'other', '--send-updates=none'], /gog_calendar_move/],
204
+ ['delete', ['primary', 'e1', '--force'], /gog_calendar_delete\b/],
205
+ ['del', ['primary', 'e1'], /gog_calendar_delete\b/],
206
+ ['rm', ['primary', 'e1'], /gog_calendar_delete\b/],
207
+ ['remove', ['primary', 'e1'], /gog_calendar_delete\b/],
208
+ ['delete-calendar', ['cal1'], /gog_calendar_delete_calendar/],
209
+ ['out-of-office', ['--from=2026-10-01T09:00:00Z', '--to=2026-10-02T09:00:00Z'], /gog_calendar_out_of_office/],
210
+ ['ooo', ['--from=2026-10-01T09:00:00Z', '--to=2026-10-02T09:00:00Z', '--auto-decline=none'], /gog_calendar_out_of_office/],
211
+ ['focus-time', ['--from=2026-10-01T09:00:00Z', '--to=2026-10-01T10:00:00Z'], /--auto-decline=none/],
212
+ ['focus', ['--from=2026-10-01T09:00:00Z', '--to=2026-10-01T10:00:00Z', '--auto-decline=all'], /--auto-decline=none/],
213
+ ['Focus-Time', ['--from=2026-10-01T09:00:00Z', '--to=2026-10-01T10:00:00Z', '--auto-decline', 'new'], /--auto-decline=none/],
214
+ ])('refuses %s %j', (sub, args, instead) => {
215
+ const refusal = vetCalendarRun(sub, args);
216
+ expect(refusal).toMatch(/not available through gog_calendar_run/);
217
+ expect(refusal).toMatch(instead);
218
+ });
219
+
220
+ it('allows a focus-time block that declines nobody', () => {
221
+ expect(vetCalendarRun('focus-time', ['--from=2026-10-01T09:00:00Z', '--to=2026-10-01T10:00:00Z', '--auto-decline=none'])).toBeUndefined();
222
+ expect(vetCalendarRun('focus', ['--from=2026-10-01T09:00:00Z', '--to=2026-10-01T10:00:00Z', '--auto-decline', 'none'])).toBeUndefined();
223
+ });
224
+
225
+ it.each(['events', 'freebusy', 'calendars', 'create-calendar', 'working-location', 'unsubscribe'])('allows %s', (sub) => {
80
226
  expect(vetCalendarRun(sub, [])).toBeUndefined();
81
227
  });
82
228
  });
@@ -85,8 +231,11 @@ describe('the vets are wired into the run tools', () => {
85
231
  it.each([
86
232
  [registerChatTools, 'gog_chat_run', { subcommand: 'messages', args: ['send', 'spaces/A', '--text=hi'] }],
87
233
  [registerDriveTools, 'gog_drive_run', { subcommand: 'share', args: ['f1', '--to=anyone'] }],
234
+ [registerDriveTools, 'gog_drive_run', { subcommand: 'bulk', args: ['update-role', '--from=reader', '--to=writer', '--depth=0', '--force'] }],
88
235
  [registerClassroomTools, 'gog_classroom_run', { subcommand: 'ann', args: ['create', 'c1', '--text=x'] }],
236
+ [registerClassroomTools, 'gog_classroom_run', { subcommand: 'announcements', args: ['update', 'c1', 'a1', '--state=PUBLISHED'] }],
89
237
  [registerCalendarTools, 'gog_calendar_run', { subcommand: 'rsvp', args: ['primary', 'e1', '--status=accepted'] }],
238
+ [registerCalendarTools, 'gog_calendar_run', { subcommand: 'propose-time', args: ['primary', 'e1', '--decline'] }],
90
239
  ] as const)('%#: %s refuses without spawning gog', async (register, tool, args) => {
91
240
  const harness = await createTestHarness(register);
92
241
  const result = await harness.callTool(tool, args);
@@ -113,6 +262,11 @@ describe('refusedApiCall — the dispatch rail\'s other escape hatch', () => {
113
262
  expect(refusedApiCall(api, method)).toContain(tool);
114
263
  });
115
264
 
265
+ // Not refused OUTRIGHT — but not waved through either. Every write here goes
266
+ // through gog_api_call's generic confirmation (SEC-2, fleet-audit #931; see
267
+ // tests/tools/api.test.ts), which asks about the exact method and payload.
268
+ // The hard refusals above are only for actions a dedicated tool already
269
+ // gates with a better preview.
116
270
  it.each([
117
271
  ['chat', 'spaces.messages.list'],
118
272
  ['drive', 'permissions.list'],
@@ -121,7 +275,7 @@ describe('refusedApiCall — the dispatch rail\'s other escape hatch', () => {
121
275
  ['calendar', 'events.list'],
122
276
  ['calendar', 'events.delete'],
123
277
  ['sheets', 'spreadsheets.values.update'],
124
- ])('allows %s %s', (api, method) => {
278
+ ])('leaves %s %s to the generic write gate', (api, method) => {
125
279
  expect(refusedApiCall(api, method)).toBeUndefined();
126
280
  });
127
281