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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +827 -315
- package/dist/lib.js +832 -281
- package/manifest.json +7 -7
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +102 -115
- package/src/gmail-dispatch-guard.ts +28 -21
- package/src/lib.ts +6 -2
- package/src/send-confirm-token.ts +17 -155
- 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 +60 -25
- package/tests/send-confirm-token.test.ts +17 -197
- 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 +74 -13
- package/tests/tools/docs.test.ts +5 -6
- package/tests/tools/drive.test.ts +2 -5
- package/tests/tools/gmail-confirm-token.test.ts +13 -11
- 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
package/tests/tools/docs.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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('{}')
|
|
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.
|
|
27
|
-
delete process.env.
|
|
28
|
-
delete process.env.
|
|
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.
|
|
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: '
|
|
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 +
|
|
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('
|
|
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 +
|
|
78
|
-
beforeEach(() => { process.env.
|
|
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(/^
|
|
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.
|
|
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
|
-
|
|
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
|
|