gogcli-mcp 4.5.0 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +537 -117
- package/dist/lib.js +576 -117
- package/manifest.json +7 -7
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +63 -3
- package/src/gmail-dispatch-guard.ts +19 -12
- package/src/lib.ts +6 -2
- package/src/tools/api.ts +74 -8
- package/src/tools/appscript.ts +63 -2
- package/src/tools/calendar.ts +83 -13
- package/src/tools/chat.ts +35 -4
- package/src/tools/classroom.ts +254 -25
- package/src/tools/docs.ts +7 -1
- package/src/tools/drive.ts +82 -10
- package/src/tools/gmail.ts +37 -1
- package/src/tools/sheets.ts +16 -1
- package/src/tools/utils.ts +10 -4
- package/tests/gmail-dispatch-guard.test.ts +24 -8
- package/tests/tools/api.test.ts +130 -5
- package/tests/tools/appscript.test.ts +6 -2
- package/tests/tools/dispatch-gates-more.test.ts +447 -0
- package/tests/tools/dispatch-gates.test.ts +52 -1
- package/tests/tools/docs.test.ts +5 -6
- package/tests/tools/drive.test.ts +2 -5
- package/tests/tools/run-vets-more.test.ts +269 -0
- package/tests/tools/run-vets.test.ts +158 -4
- package/tests/tools/sheets.test.ts +10 -11
- package/tests/tools/utils.test.ts +38 -41
|
@@ -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
|
-
|
|
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
|
-
['
|
|
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
|
-
|
|
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
|
-
])('
|
|
278
|
+
])('leaves %s %s to the generic write gate', (api, method) => {
|
|
125
279
|
expect(refusedApiCall(api, method)).toBeUndefined();
|
|
126
280
|
});
|
|
127
281
|
|
|
@@ -27,13 +27,13 @@ describe('gog_sheets_get', () => {
|
|
|
27
27
|
expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', pos('sid'), pos('A1')], { account: 'other@gmail.com' });
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
it('appends
|
|
30
|
+
it('appends the account list only on an auth failure', async () => {
|
|
31
31
|
vi.mocked(runner.run)
|
|
32
|
-
.mockRejectedValueOnce(new Error('Spreadsheet not found'))
|
|
33
|
-
.mockResolvedValueOnce('user@gmail.com');
|
|
32
|
+
.mockRejectedValueOnce(new Error('Spreadsheet not found'));
|
|
34
33
|
const harness = await setupHandlers();
|
|
35
34
|
const result = await harness.callTool('gog_sheets_get', { spreadsheetId: 'bad', range: 'A1' });
|
|
36
|
-
expect(result.content[0].text).toBe('Error: Spreadsheet not found
|
|
35
|
+
expect(result.content[0].text).toBe('Error: Spreadsheet not found');
|
|
36
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
it('returns plain error text when auth list also fails', async () => {
|
|
@@ -45,8 +45,7 @@ describe('gog_sheets_get', () => {
|
|
|
45
45
|
|
|
46
46
|
it('handles non-Error rejection', async () => {
|
|
47
47
|
vi.mocked(runner.run)
|
|
48
|
-
.mockRejectedValueOnce('raw error string')
|
|
49
|
-
.mockRejectedValueOnce(new Error('auth list failed'));
|
|
48
|
+
.mockRejectedValueOnce('raw error string');
|
|
50
49
|
const harness = await setupHandlers();
|
|
51
50
|
const result = await harness.callTool('gog_sheets_get', { spreadsheetId: 'bad', range: 'A1' });
|
|
52
51
|
expect(result.content[0].text).toBe('raw error string');
|
|
@@ -162,17 +161,17 @@ describe('gog_sheets_update fail_if_not_empty guard', () => {
|
|
|
162
161
|
|
|
163
162
|
it('aborts without writing and diagnoses the error when the verification read fails', async () => {
|
|
164
163
|
vi.mocked(runner.run)
|
|
165
|
-
.mockRejectedValueOnce(new Error('read boom')) // the verification get
|
|
166
|
-
.mockResolvedValueOnce('{"accounts":[{"email":"u@x.com"}]}'); // diagnose -> auth list
|
|
164
|
+
.mockRejectedValueOnce(new Error('read boom')); // the verification get
|
|
167
165
|
const harness = await setupHandlers();
|
|
168
166
|
const result = await harness.callTool('gog_sheets_update', {
|
|
169
167
|
spreadsheetId: 'sid', range: 'A1', values: [['new']], fail_if_not_empty: true,
|
|
170
168
|
});
|
|
171
|
-
// get
|
|
172
|
-
|
|
169
|
+
// the get only — the update is never attempted, and a non-auth failure
|
|
170
|
+
// does not consult `auth list` (PRIV-1)
|
|
171
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
173
172
|
expect(vi.mocked(runner.run).mock.calls.some((c) => c[0][1] === 'update')).toBe(false);
|
|
174
173
|
expect(result.content[0].text).toContain('Error: read boom');
|
|
175
|
-
expect(result.content[0].text).toContain('Configured accounts:');
|
|
174
|
+
expect(result.content[0].text).not.toContain('Configured accounts:');
|
|
176
175
|
});
|
|
177
176
|
|
|
178
177
|
it('surfaces the re-auth hint when the verification read fails with an auth error', async () => {
|