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.
@@ -3,26 +3,105 @@ import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken} from './utils.js';
4
4
  import { pos } from '../argv.js';
5
5
  import type { GogArg } from '../runner.js';
6
- import { bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, hasCommandWord, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
6
+ import { bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, flagValue, gatedElsewhere, hasCommandWord, refusedInRun, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
7
7
 
8
- // gog's spellings (internal/cmd/classroom.go, classroom_announcements.go, classroom_invitations.go).
8
+ // gog's spellings (internal/cmd/classroom*.go; aliases per `gog schema` 0.41.0),
9
+ // each alias mapped to the resource it names.
9
10
  const CLASSROOM_CREATE_WORDS = new Set(['create', 'add', 'new']);
10
- const CLASSROOM_GATED: Record<string, { does: string; tool: string }> = {
11
- announcements: { does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
12
- announcement: { does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
13
- ann: { does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
14
- invitations: { does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
15
- invitation: { does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
16
- invites: { does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
11
+ const CLASSROOM_DELETE_WORDS = new Set(['delete', 'rm', 'del', 'remove']);
12
+ const CLASSROOM_UPDATE_WORDS = new Set(['update', 'edit', 'set']);
13
+ const CLASSROOM_RETURN_WORDS = new Set(['return', 'send']);
14
+ const CLASSROOM_ASSIGNEE_WORDS = new Set(['assignees', 'assign']);
15
+ type ClassroomGroup = 'courses' | 'announcements' | 'invitations' | 'coursework' | 'materials' | 'students' | 'teachers' | 'submissions' | 'guardian-invitations';
16
+ const CLASSROOM_GROUPS: Record<string, ClassroomGroup> = {
17
+ courses: 'courses', course: 'courses',
18
+ announcements: 'announcements', announcement: 'announcements', ann: 'announcements',
19
+ invitations: 'invitations', invitation: 'invitations', invites: 'invitations',
20
+ coursework: 'coursework', work: 'coursework',
21
+ materials: 'materials', material: 'materials',
22
+ students: 'students', student: 'students',
23
+ teachers: 'teachers', teacher: 'teachers',
24
+ submissions: 'submissions', submission: 'submissions',
25
+ 'guardian-invitations': 'guardian-invitations', 'guardian-invites': 'guardian-invitations',
17
26
  };
18
27
 
19
- /** gog_classroom_run must not post or invite what the dedicated tools would ask about. */
28
+ /**
29
+ * True when forwarded flags publish existing work to students: state
30
+ * PUBLISHED publishes now; a schedule publishes then (a scheduled item is a
31
+ * draft that publishes itself). A text edit or a return to DRAFT is neither.
32
+ */
33
+ function publishesToStudents(args: readonly string[]): boolean {
34
+ return flagValue(args, 'state')?.toUpperCase() === 'PUBLISHED' || flagValue(args, 'scheduled') !== undefined;
35
+ }
36
+
37
+ /** True when a create reaches students: anything but an unscheduled DRAFT (gog's default state is PUBLISHED). */
38
+ function createsVisibleWork(args: readonly string[]): boolean {
39
+ return flagValue(args, 'state')?.toUpperCase() !== 'DRAFT' || flagValue(args, 'scheduled') !== undefined;
40
+ }
41
+
42
+ /** True when an assignees change shows an item to more students: ALL_STUDENTS or an added student. Removing students only narrows it. */
43
+ function widensAssignees(args: readonly string[]): boolean {
44
+ return flagValue(args, 'mode')?.toUpperCase() === 'ALL_STUDENTS' || flagValue(args, 'add-student') !== undefined;
45
+ }
46
+
47
+ /**
48
+ * gog_classroom_run must not post, publish, enrol, return or invite what the
49
+ * dedicated tools would ask about (#400; SEC-3, fleet-audit #932; SEC-6).
50
+ * Publishing through `update --state=PUBLISHED` was the two-step around the
51
+ * create gate that closed for Gmail drafts, so the update words are vetted on
52
+ * their flags. Deletes that destroy a course or coursework (and every
53
+ * submission to it) go to the tools that ask first.
54
+ */
20
55
  export function vetClassroomRun(subcommand: string, args: readonly string[]): string | undefined {
21
56
  const sub = subcommand.toLowerCase();
22
- const gated = Object.hasOwn(CLASSROOM_GATED, sub) ? CLASSROOM_GATED[sub] : undefined;
23
- if (!gated) return undefined;
24
- const word = hasCommandWord(args, CLASSROOM_CREATE_WORDS);
25
- return word ? gatedElsewhere(`gog classroom ${sub} ${word.toLowerCase()}`, 'gog_classroom_run', gated.does, gated.tool) : undefined;
57
+ if (!Object.hasOwn(CLASSROOM_GROUPS, sub)) return undefined;
58
+ const group = CLASSROOM_GROUPS[sub]!;
59
+ const via = 'gog_classroom_run';
60
+ const what = (word: string) => `gog classroom ${sub} ${word.toLowerCase()}`;
61
+ const create = hasCommandWord(args, CLASSROOM_CREATE_WORDS);
62
+ const update = hasCommandWord(args, CLASSROOM_UPDATE_WORDS);
63
+ const remove = hasCommandWord(args, CLASSROOM_DELETE_WORDS);
64
+ const widenedAssignees = (): string | undefined => {
65
+ const word = hasCommandWord(args, CLASSROOM_ASSIGNEE_WORDS);
66
+ return word && widensAssignees(args)
67
+ ? refusedInRun(what(word), via, 'shows it to more students', 'Ask the user to change who it is assigned to from Classroom.')
68
+ : undefined;
69
+ };
70
+ switch (group) {
71
+ case 'courses':
72
+ return remove ? gatedElsewhere(what(remove), via, 'deletes a course', 'gog_classroom_courses_delete') : undefined;
73
+ case 'announcements':
74
+ if (create) return gatedElsewhere(what(create), via, 'posts to a class', 'gog_classroom_announcements_create');
75
+ if (update && publishesToStudents(args)) return gatedElsewhere(what(update), via, 'publishes an announcement to a class', 'gog_classroom_announcements_update');
76
+ return widenedAssignees();
77
+ case 'invitations':
78
+ return create ? gatedElsewhere(what(create), via, 'invites someone to a class', 'gog_classroom_invitations_create') : undefined;
79
+ case 'coursework':
80
+ // An unscheduled DRAFT reaches nobody, and gog_classroom_coursework_create
81
+ // does not ask about one either; publishing it later is the update below.
82
+ if (create && createsVisibleWork(args)) return gatedElsewhere(what(create), via, 'posts coursework to a class', 'gog_classroom_coursework_create');
83
+ if (remove) return gatedElsewhere(what(remove), via, 'deletes coursework and every submission to it', 'gog_classroom_coursework_delete');
84
+ if (update && publishesToStudents(args)) return gatedElsewhere(what(update), via, 'publishes work to students', 'gog_classroom_coursework_update');
85
+ return widenedAssignees();
86
+ case 'materials':
87
+ // No dedicated tool updates materials, so publishing one is refused
88
+ // outright rather than sent to a gated tool.
89
+ if (create && createsVisibleWork(args)) {
90
+ return refusedInRun(what(create), via, 'publishes material to students', 'Create it with --state=DRAFT and no --scheduled; ask the user to publish it from Classroom.');
91
+ }
92
+ if (update && publishesToStudents(args)) return refusedInRun(what(update), via, 'publishes material to students', 'Ask the user to publish it from Classroom.');
93
+ return undefined;
94
+ case 'students':
95
+ return create ? gatedElsewhere(what(create), via, 'enrols someone in a class', 'gog_classroom_students_add') : undefined;
96
+ case 'teachers':
97
+ return create ? gatedElsewhere(what(create), via, "gives someone a teacher's access to a class and its roster", 'gog_classroom_teachers_add') : undefined;
98
+ case 'submissions': {
99
+ const word = hasCommandWord(args, CLASSROOM_RETURN_WORDS);
100
+ return word ? gatedElsewhere(what(word), via, 'returns work to a student, who is notified', 'gog_classroom_submissions_return') : undefined;
101
+ }
102
+ case 'guardian-invitations':
103
+ return create ? refusedInRun(what(create), via, 'emails a guardian invitation', 'Ask the user to invite the guardian from Classroom.') : undefined;
104
+ }
26
105
  }
27
106
 
28
107
  /**
@@ -55,6 +134,127 @@ export async function readCourse(
55
134
  };
56
135
  }
57
136
 
137
+ /** JSON object under `key` in a gog result, or undefined for unreadable output. */
138
+ function nested(raw: string, key: string): Record<string, unknown> | undefined {
139
+ try {
140
+ const parsed = JSON.parse(raw) as Record<string, unknown> | null;
141
+ const inner = parsed?.[key];
142
+ return inner && typeof inner === 'object' ? inner as Record<string, unknown> : undefined;
143
+ } catch {
144
+ return undefined;
145
+ }
146
+ }
147
+
148
+ const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
149
+ const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
150
+
151
+ /** The coursework a Classroom dispatch touches, by title. Same contract as {@link readCourse}. */
152
+ export async function readCoursework(
153
+ courseId: string,
154
+ courseworkId: string,
155
+ account: string | undefined,
156
+ runner: typeof runOrDiagnose = runOrDiagnose,
157
+ ) {
158
+ const got = await runner(['classroom', 'coursework', 'get', pos(courseId), pos(courseworkId)], { account });
159
+ if (got.isError) return { error: got };
160
+ const work = nested(resultText(got), 'coursework');
161
+ const title = str(work?.title);
162
+ return { coursework: { id: courseworkId, ...(title !== undefined ? { title } : {}) } };
163
+ }
164
+
165
+ /** The Classroom items a publish-through-update reaches students with. */
166
+ export type ClassroomWorkKind = 'announcements' | 'coursework';
167
+
168
+ /** What {@link readClassroomWork} names about an item; each field only when gog returned it as a string. */
169
+ export interface ClassroomWork {
170
+ id: string;
171
+ text?: string;
172
+ title?: string;
173
+ description?: string;
174
+ state?: string;
175
+ updateTime?: string;
176
+ }
177
+
178
+ /**
179
+ * The announcement or coursework a publish reaches students with, for its
180
+ * confirmation prompt (the text, or the title and description: a user
181
+ * approving "publish a1" cannot tell what a1 says) and as the token fallback's
182
+ * revision (updateTime rotates on every edit, so an approval never publishes
183
+ * text it did not name). Read on every call. gog 0.41.0 nests the payload
184
+ * under `announcement` or `coursework` (internal/cmd/classroom_*.go; not the
185
+ * API's `courseWork`); unreadable output names nothing rather than throwing.
186
+ */
187
+ export async function readClassroomWork(
188
+ kind: ClassroomWorkKind,
189
+ courseId: string,
190
+ itemId: string,
191
+ account: string | undefined,
192
+ // A sub-package passes the runOrDiagnose it imported from lib.js (see readCourse).
193
+ runner: typeof runOrDiagnose = runOrDiagnose,
194
+ ): Promise<{ error: Awaited<ReturnType<typeof runOrDiagnose>>; work?: undefined } | { error?: undefined; work: ClassroomWork }> {
195
+ const got = await runner(['classroom', kind, 'get', pos(courseId), pos(itemId)], { account });
196
+ if (got.isError) return { error: got };
197
+ const item = nested(resultText(got), kind === 'announcements' ? 'announcement' : 'coursework');
198
+ const work: ClassroomWork = { id: itemId };
199
+ for (const key of ['text', 'title', 'description', 'state', 'updateTime'] as const) {
200
+ const value = str(item?.[key]);
201
+ if (value !== undefined) work[key] = value;
202
+ }
203
+ return { work };
204
+ }
205
+
206
+ /**
207
+ * A submission as a return's prompt shows it — whose it is, its state and
208
+ * grades — plus its `updateTime` apart, as the token fallback's revision: a
209
+ * submission re-graded between the phases is DRAFT_CHANGED, not returned with a
210
+ * grade the user never saw.
211
+ */
212
+ export async function readSubmission(
213
+ courseId: string,
214
+ courseworkId: string,
215
+ submissionId: string,
216
+ account: string | undefined,
217
+ runner: typeof runOrDiagnose = runOrDiagnose,
218
+ ) {
219
+ const got = await runner(['classroom', 'submissions', 'get', pos(courseId), pos(courseworkId), pos(submissionId)], { account });
220
+ if (got.isError) return { error: got };
221
+ const sub = nested(resultText(got), 'submission');
222
+ const fields = {
223
+ student: str(sub?.userId),
224
+ state: str(sub?.state),
225
+ draftGrade: num(sub?.draftGrade),
226
+ assignedGrade: num(sub?.assignedGrade),
227
+ };
228
+ const updateTime = str(sub?.updateTime);
229
+ return {
230
+ submission: {
231
+ id: submissionId,
232
+ ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)),
233
+ } as { id: string; student?: string; state?: string; draftGrade?: number; assignedGrade?: number },
234
+ ...(updateTime !== undefined ? { updateTime } : {}),
235
+ };
236
+ }
237
+
238
+ /**
239
+ * A person on a course roster by name and email, for a prompt that would
240
+ * otherwise show only a numeric user id. Best effort: an unreadable or failed
241
+ * read falls back to the id the caller has.
242
+ */
243
+ export async function studentLabel(
244
+ courseId: string,
245
+ userId: string,
246
+ account: string | undefined,
247
+ runner: typeof runOrDiagnose = runOrDiagnose,
248
+ ): Promise<string> {
249
+ const got = await runner(['classroom', 'students', 'get', pos(courseId), pos(userId)], { account });
250
+ if (got.isError) return userId;
251
+ const profile = nested(resultText(got), 'student')?.profile as { name?: { fullName?: unknown }; emailAddress?: unknown } | undefined;
252
+ const name = str(profile?.name?.fullName);
253
+ const email = str(profile?.emailAddress);
254
+ if (name && email) return `${name} <${email}>`;
255
+ return name ?? email ?? userId;
256
+ }
257
+
58
258
  export function registerClassroomTools(server: McpServer): void {
59
259
  server.registerTool('gog_classroom_courses_list', {
60
260
  description: 'List Google Classroom courses.',
@@ -279,15 +479,43 @@ export function registerClassroomTools(server: McpServer): void {
279
479
  });
280
480
 
281
481
  server.registerTool('gog_classroom_submissions_return', {
282
- description: 'Return a graded submission to the student.',
482
+ description: 'Return a graded submission to the student, who is notified and sees the grade. Reads the course, the '
483
+ + 'coursework and the submission and asks the MCP host to show the user a confirmation prompt with the class, the '
484
+ + 'assignment, the student and the grade being returned; nothing is returned unless they accept.'
485
+ + CONFIRM_FALLBACK_DESCRIPTION,
283
486
  annotations: { destructiveHint: true },
284
487
  inputSchema: z.object({
285
488
  courseId: z.string().describe('Course ID'),
286
489
  courseworkId: z.string().describe('Coursework ID'),
287
490
  submissionId: z.string().describe('Submission ID'),
288
491
  account: accountParam,
492
+ confirmToken: confirmTokenParam,
289
493
  }),
290
- }, async ({ courseId, courseworkId, submissionId, account }) => {
494
+ }, async ({ courseId, courseworkId, submissionId, account, confirmToken }, ctx) => {
495
+ const course = await readCourse(courseId, account);
496
+ if (course.error) return course.error;
497
+ const work = await readCoursework(courseId, courseworkId, account);
498
+ if (work.error) return work.error;
499
+ const sub = await readSubmission(courseId, courseworkId, submissionId, account);
500
+ if (sub.error) return sub.error;
501
+ const submission = sub.submission.student
502
+ ? { ...sub.submission, student: await studentLabel(courseId, sub.submission.student, account) }
503
+ : sub.submission;
504
+ const view = { course: course.course, coursework: work.coursework, submission };
505
+ const confirmation = await requireDispatchConfirmation(ctx, {
506
+ action: 'classroom.submission-return',
507
+ message: 'Review and confirm returning this work — the student is notified and sees the grade:',
508
+ confirmationLabel: 'Confirm that this submission should be returned to the student now.',
509
+ details: view,
510
+ unsupportedNote: 'Ask the user to return it from Classroom.',
511
+ fallback: {
512
+ tool: 'gog_classroom_submissions_return',
513
+ account,
514
+ confirmToken,
515
+ subject: () => ({ target: `${courseId}/${courseworkId}/${submissionId}`, revision: sub.updateTime, payload: view, preview: view }),
516
+ },
517
+ });
518
+ if (confirmation) return confirmation;
291
519
  return runOrDiagnose(['classroom', 'submissions', 'return', pos(courseId), pos(courseworkId), pos(submissionId)], { account });
292
520
  });
293
521
 
@@ -354,16 +582,16 @@ export function registerClassroomTools(server: McpServer): void {
354
582
  });
355
583
 
356
584
  server.registerTool('gog_classroom_announcements_create', {
357
- description: 'Create an announcement in a Google Classroom course. Unless state is DRAFT (which students cannot '
358
- + 'see), this reads the course and asks the MCP host to show the user a confirmation prompt with the class, the '
359
- + 'full text and when it publishes; nothing is posted unless they accept. To stage one without asking, pass '
360
- + 'state DRAFT.' + CONFIRM_FALLBACK_DESCRIPTION,
585
+ description: 'Create an announcement in a Google Classroom course. Unless state is DRAFT with no scheduled time '
586
+ + '(which students cannot see; a scheduled draft publishes itself), this reads the course and asks the MCP host to '
587
+ + 'show the user a confirmation prompt with the class, the full text and when it publishes; nothing is posted '
588
+ + 'unless they accept. To stage one without asking, pass state DRAFT and no scheduled time.' + CONFIRM_FALLBACK_DESCRIPTION,
361
589
  annotations: { destructiveHint: true },
362
590
  inputSchema: z.object({
363
591
  courseId: z.string().describe('Course ID'),
364
592
  text: z.string().describe('Announcement text'),
365
- state: z.enum(['PUBLISHED', 'DRAFT']).optional().describe('State (DRAFT is visible only to teachers and needs no confirmation)'),
366
- scheduled: z.string().optional().describe('Scheduled publish time'),
593
+ state: z.enum(['PUBLISHED', 'DRAFT']).optional().describe('State (an unscheduled DRAFT is visible only to teachers and needs no confirmation)'),
594
+ scheduled: z.string().optional().describe('Scheduled publish time (asks the user to confirm, even for a DRAFT)'),
367
595
  account: accountParam,
368
596
  confirmToken: confirmTokenParam,
369
597
  }),
@@ -371,9 +599,10 @@ export function registerClassroomTools(server: McpServer): void {
371
599
  const args: GogArg[] = ['classroom', 'announcements', 'create', pos(courseId), `--text=${text}`];
372
600
  if (state) args.push(`--state=${state}`);
373
601
  if (scheduled) args.push(`--scheduled=${scheduled}`);
374
- // A draft reaches nobody until a teacher publishes it: it is this tool's
375
- // own staging twin, so it needs no confirmation.
376
- if (state !== 'DRAFT') {
602
+ // An unscheduled draft reaches nobody until a teacher publishes it: it is
603
+ // this tool's own staging twin, so it needs no confirmation. A scheduled
604
+ // one publishes itself.
605
+ if (state !== 'DRAFT' || scheduled) {
377
606
  const read = await readCourse(courseId, account);
378
607
  if (read.error) return read.error;
379
608
  const publishes = scheduled ? `at ${scheduled}` : 'immediately';
package/src/tools/docs.ts CHANGED
@@ -3,6 +3,12 @@ import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
4
4
  import { pos } from '../argv.js';
5
5
  import type { GogArg } from '../runner.js';
6
+ import { vetCommentsRun } from '../dispatch-confirmation.js';
7
+
8
+ /** gog_docs_run must not post the comments gog_docs_comments_add / _reply would ask about. */
9
+ export function vetDocsRun(subcommand: string, args: readonly string[]): string | undefined {
10
+ return vetCommentsRun('docs', subcommand, args);
11
+ }
6
12
 
7
13
  export function registerDocsTools(server: McpServer): void {
8
14
  server.registerTool('gog_docs_info', {
@@ -106,5 +112,5 @@ export function registerDocsTools(server: McpServer): void {
106
112
  return runOrDiagnose(['docs', 'structure', pos(docId)], { account });
107
113
  });
108
114
 
109
- registerRunTool(server, { service: 'docs', examples: '"copy", "clear", "insert", "sed", "export"' });
115
+ registerRunTool(server, { service: 'docs', examples: '"copy", "clear", "insert", "sed", "export"', vet: vetDocsRun });
110
116
  }
@@ -8,13 +8,32 @@ import { run, runBinary } from '../runner.js';
8
8
  import { accountParam, diagnose, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken} from './utils.js';
9
9
  import { pos } from '../argv.js';
10
10
  import type { GogArg } from '../runner.js';
11
- import { CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
11
+ import { CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, hasTrueFlag, refusedInRun, requireDispatchConfirmation, vetCommentsRun, resultText } from '../dispatch-confirmation.js';
12
12
 
13
- /** gog_drive_run must not grant access that gog_drive_share would ask about. */
14
- export function vetDriveRun(subcommand: string, _args: readonly string[]): string | undefined {
15
- return subcommand.toLowerCase() === 'share'
16
- ? gatedElsewhere('gog drive share', 'gog_drive_run', 'grants access to a file', 'gog_drive_share')
17
- : undefined;
13
+ // gog's spellings (internal/cmd/drive*.go; aliases per `gog schema` 0.41.0).
14
+ const DRIVE_DELETE_WORDS = new Set(['delete', 'rm', 'del']);
15
+
16
+ /** gog_drive_run must not do what gog_drive_share / comments / a permanent delete would ask about. */
17
+ export function vetDriveRun(subcommand: string, args: readonly string[]): string | undefined {
18
+ const sub = subcommand.toLowerCase();
19
+ if (sub === 'share') return gatedElsewhere('gog drive share', 'gog_drive_run', 'grants access to a file', 'gog_drive_share');
20
+ if (DRIVE_DELETE_WORDS.has(sub) && hasTrueFlag(args, 'permanent')) {
21
+ return gatedElsewhere(`gog drive ${sub} --permanent`, 'gog_drive_run', 'deletes a file for good, bypassing the trash', 'gog_drive_delete');
22
+ }
23
+ // `bulk update-role --from=reader --to=writer` (and `bulk remove-public`)
24
+ // rewrites the permissions of every matching file under a folder, with no
25
+ // positional path for the path guard to see and no file for a preview to
26
+ // name (SEC-1, fleet-audit #930). `permissions` only lists.
27
+ if (sub === 'bulk') {
28
+ return gatedElsewhere('gog drive bulk', 'gog_drive_run',
29
+ 'rewrites sharing permissions across every matching file in a tree', 'gog_drive_share (one file at a time)');
30
+ }
31
+ // `unshare` removes a collaborator's access; the dedicated tool shows the
32
+ // host a structured, annotated call rather than an opaque argv.
33
+ if (sub === 'unshare') {
34
+ return refusedInRun('gog drive unshare', 'gog_drive_run', "removes someone's access to a file", 'Use gog_drive_unshare.');
35
+ }
36
+ return vetCommentsRun('drive', sub, args);
18
37
  }
19
38
 
20
39
  // A native Google Doc exports to text directly; anything else (PDF, image,
@@ -36,6 +55,36 @@ export function shareTargetMeta(raw: string): { name?: string; mimeType?: string
36
55
  }
37
56
  }
38
57
 
58
+ /**
59
+ * A Drive/Docs comment as a reply's prompt shows it: who wrote it, what it
60
+ * says and whether it is resolved. Unreadable output names nothing.
61
+ */
62
+ export function commentSnapshot(raw: string): { author?: string; content?: string; resolved?: boolean } {
63
+ let comment: { content?: unknown; resolved?: unknown; author?: { displayName?: unknown; emailAddress?: unknown } } | undefined;
64
+ try {
65
+ const parsed = JSON.parse(raw) as ({ comment?: typeof comment } & NonNullable<typeof comment>) | null;
66
+ comment = parsed?.comment ?? parsed ?? undefined;
67
+ } catch {
68
+ comment = undefined;
69
+ }
70
+ const name = typeof comment?.author?.displayName === 'string' ? comment.author.displayName : undefined;
71
+ const email = typeof comment?.author?.emailAddress === 'string' ? comment.author.emailAddress : undefined;
72
+ const author = name && email ? `${name} <${email}>` : name ?? email;
73
+ return {
74
+ ...(author ? { author } : {}),
75
+ ...(typeof comment?.content === 'string' ? { content: comment.content } : {}),
76
+ ...(typeof comment?.resolved === 'boolean' ? { resolved: comment.resolved } : {}),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The people a comment's text +mentions (or @mentions) by email — Drive
82
+ * notifies each of them, so a comment's prompt names them.
83
+ */
84
+ export function commentMentions(text: string): string[] {
85
+ return [...new Set(text.match(/(?<=[+@])[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g) ?? [])];
86
+ }
87
+
39
88
  function fileMeta(raw: string): { name?: string; mimeType?: string; size?: number } {
40
89
  const parsed = JSON.parse(raw) as { file?: DriveMeta } & DriveMeta;
41
90
  const f = parsed.file ?? parsed;
@@ -171,16 +220,39 @@ export function registerDriveTools(server: McpServer): void {
171
220
  });
172
221
 
173
222
  server.registerTool('gog_drive_delete', {
174
- description: 'Move a Google Drive file to trash, or permanently delete it with permanent=true (irreversible).',
223
+ description: 'Move a Google Drive file to trash, or permanently delete it with permanent=true (irreversible). A '
224
+ + 'permanent delete reads the file and asks the MCP host to show the user a confirmation prompt naming it first; '
225
+ + 'nothing is deleted unless they accept. Moving to trash is recoverable and does not ask.'
226
+ + CONFIRM_FALLBACK_DESCRIPTION,
175
227
  annotations: { destructiveHint: true },
176
228
  inputSchema: z.object({
177
229
  fileId: z.string().describe('File ID to delete'),
178
- permanent: z.boolean().optional().describe('Permanently delete instead of moving to trash (irreversible)'),
230
+ permanent: z.boolean().optional().describe('Permanently delete instead of moving to trash (irreversible; asks the user first)'),
179
231
  account: accountParam,
232
+ confirmToken: confirmTokenParam,
180
233
  }),
181
- }, async ({ fileId, permanent, account }) => {
234
+ }, async ({ fileId, permanent, account, confirmToken }, ctx) => {
182
235
  const args: GogArg[] = ['drive', 'delete', pos(fileId)];
183
- if (permanent) args.push('--permanent');
236
+ if (permanent) {
237
+ const got = await runOrDiagnose(['drive', 'get', pos(fileId)], { account });
238
+ if (got.isError) return got;
239
+ const target = { file: { id: fileId, ...shareTargetMeta(resultText(got)) }, permanent: true };
240
+ const confirmation = await requireDispatchConfirmation(ctx, {
241
+ action: 'drive.delete-permanent',
242
+ message: 'Review and confirm PERMANENTLY deleting this file — it skips the trash and cannot be recovered:',
243
+ confirmationLabel: 'Confirm that this file should be permanently deleted now.',
244
+ details: target,
245
+ unsupportedNote: 'Move it to the trash instead (permanent=false); the user can empty the trash from Google Drive.',
246
+ fallback: {
247
+ tool: 'gog_drive_delete',
248
+ account,
249
+ confirmToken,
250
+ subject: () => ({ target: fileId, payload: target, preview: target }),
251
+ },
252
+ });
253
+ if (confirmation) return confirmation;
254
+ args.push('--permanent');
255
+ }
184
256
  // gog gates drive delete behind a confirmation; the runner injects
185
257
  // --no-input, so without --force it refuses at runtime.
186
258
  args.push('--force');
@@ -7,7 +7,7 @@ import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
7
7
  import type { InlineAttachmentInput } from '../attachments.js';
8
8
  import { attachmentDetails, attachmentNames, attachmentPreview, bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText, senderPreview } from '../gmail-dispatch-guard.js';
9
9
  import { pos } from '../argv.js';
10
- import { hasCommandWord } from '../dispatch-confirmation.js';
10
+ import { gatedElsewhere, hasCommandWord, hasTrueFlag } from '../dispatch-confirmation.js';
11
11
  import { confinePath, confinePaths } from '../file-roots.js';
12
12
 
13
13
  // gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
@@ -264,6 +264,40 @@ async function sendReply(
264
264
  // `gog schema`, but they reach Google), so both spellings are refused.
265
265
  const GMAIL_RUN_BLOCKED_SETTINGS = new Set(['forwarding', 'autoforward', 'filters', 'delegates']);
266
266
 
267
+ // Fleet audit 2026-09-24 SEC-6: a batch delete bypasses the Trash, a send-as
268
+ // alias makes Google email the target and gives the account a new sending
269
+ // identity, and an enabled vacation responder auto-replies to every sender.
270
+ // Each has a dedicated tool that asks; gog accepts sendas and vacation both
271
+ // under `settings` and one level up. Aliases per `gog schema` 0.41.0.
272
+ const GMAIL_DELETE_WORDS = new Set(['delete', 'rm', 'del', 'remove']);
273
+ const GMAIL_CREATE_WORDS = new Set(['create', 'add', 'new']);
274
+ const GMAIL_UPDATE_WORDS = new Set(['update', 'edit', 'set']);
275
+ const SENDAS = new Set(['sendas']);
276
+ const VACATION = new Set(['vacation']);
277
+
278
+ function vetGmailGatedRun(sub: string, args: readonly string[]): string | undefined {
279
+ if (sub === 'batch') {
280
+ const word = hasCommandWord(args, GMAIL_DELETE_WORDS);
281
+ return word
282
+ ? gatedElsewhere(`gog gmail batch ${word.toLowerCase()}`, 'gog_gmail_run', 'deletes mail for good, bypassing the Trash', 'gog_gmail_batch_delete')
283
+ : undefined;
284
+ }
285
+ if (sub !== 'settings' && sub !== 'sendas' && sub !== 'vacation') return undefined;
286
+ const words = sub === 'settings' ? args : [sub, ...args];
287
+ if (hasCommandWord(words, SENDAS)) {
288
+ const word = hasCommandWord(args, GMAIL_CREATE_WORDS);
289
+ if (word) {
290
+ return gatedElsewhere(`gog gmail sendas ${word.toLowerCase()}`, 'gog_gmail_run',
291
+ 'makes Google email the address and adds a sending identity', 'gog_gmail_sendas_create');
292
+ }
293
+ }
294
+ if (hasCommandWord(words, VACATION) && hasCommandWord(args, GMAIL_UPDATE_WORDS) && hasTrueFlag(args, 'enable')) {
295
+ return gatedElsewhere('gog gmail vacation update --enable', 'gog_gmail_run',
296
+ 'turns on an auto-reply to every sender', 'gog_gmail_vacation_update');
297
+ }
298
+ return undefined;
299
+ }
300
+
267
301
  export function vetGmailRun(subcommand: string, args: readonly string[]): string | undefined {
268
302
  if (subcommand === 'autoreply') {
269
303
  return 'gog gmail autoreply sends mail and is not available through gog_gmail_run. Use gog_gmail_autoreply, which asks the user to confirm.';
@@ -271,6 +305,8 @@ export function vetGmailRun(subcommand: string, args: readonly string[]): string
271
305
  if (GMAIL_RUN_BLOCKED_SETTINGS.has(subcommand)) {
272
306
  return `gog gmail ${subcommand} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
273
307
  }
308
+ const gated = vetGmailGatedRun(subcommand.toLowerCase(), args);
309
+ if (gated) return gated;
274
310
  if (subcommand === 'settings') {
275
311
  // A global flag can take its value as the next token (`settings --color
276
312
  // never filters ...`), so the word is not necessarily args[0]. Refuse it
@@ -6,6 +6,21 @@ import { accountParam, runOrDiagnose, registerRunTool, diagnose } from './utils.
6
6
  import { expandAnchorRange, countNonEmptyCells } from './sheets-a1.js';
7
7
  import { pos } from '../argv.js';
8
8
  import type { GogArg } from '../runner.js';
9
+ import { gatedElsewhere, hasCommandWord } from '../dispatch-confirmation.js';
10
+
11
+ // Fleet audit 2026-09-24 SEC-6: each of these starts a BigQuery job billed to
12
+ // --billing-project. Spellings per `gog schema` 0.41.0.
13
+ const DATASOURCE_ALIASES = new Set(['datasource', 'data-source', 'data-sources', 'connected-sheets']);
14
+ const DATASOURCE_BILLED = new Set(['add', 'update', 'refresh']);
15
+
16
+ /** gog_sheets_run must not start the billed queries gog_sheets_datasource_* would ask about. */
17
+ export function vetSheetsRun(subcommand: string, args: readonly string[]): string | undefined {
18
+ if (!DATASOURCE_ALIASES.has(subcommand.toLowerCase())) return undefined;
19
+ const word = hasCommandWord(args, DATASOURCE_BILLED)?.toLowerCase();
20
+ return word
21
+ ? gatedElsewhere(`gog sheets datasource ${word}`, 'gog_sheets_run', 'runs a billed BigQuery job', `gog_sheets_datasource_${word}`)
22
+ : undefined;
23
+ }
9
24
 
10
25
  // Cell value type: matches what gog sheets --values-json accepts (passed
11
26
  // straight to the Sheets API as userEnteredValue). Strings starting with
@@ -144,5 +159,5 @@ export function registerSheetsTools(server: McpServer): void {
144
159
  return runOrDiagnose(['sheets', 'find-replace', pos(spreadsheetId), pos(find), pos(replace)], { account });
145
160
  });
146
161
 
147
- registerRunTool(server, { service: 'sheets', examples: '"freeze", "add-tab", "rename-tab"' });
162
+ registerRunTool(server, { service: 'sheets', examples: '"freeze", "add-tab", "rename-tab"', vet: vetSheetsRun });
148
163
  }
@@ -302,10 +302,11 @@ export function formatAccountList(raw: string): string {
302
302
 
303
303
  // Turn a thrown error into a diagnosed error result (`isError: true`): the
304
304
  // error text, an actionable hint when the failure class is recognised (auth /
305
- // transient / off-grid write), and the list of configured accounts. Callers
306
- // that need to surface a failure without going through runOrDiagnose (e.g. a
307
- // pre-write verification read that must abort) can reuse this so the error
308
- // keeps the same diagnostic quality as everywhere else.
305
+ // transient / off-grid write), and — on an auth failure only — the list of
306
+ // configured accounts. Callers that need to surface a failure without going
307
+ // through runOrDiagnose (e.g. a pre-write verification read that must abort)
308
+ // can reuse this so the error keeps the same diagnostic quality as everywhere
309
+ // else.
309
310
  export async function diagnose(err: unknown): Promise<CallToolResult> {
310
311
  const errText = errorText(err);
311
312
 
@@ -333,6 +334,11 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
333
334
  : isGridLimitError
334
335
  ? GRID_LIMIT_HINT
335
336
  : '';
337
+ // The account list answers one question — WHICH account is signed in — so it
338
+ // is appended only when the failure is about that (PRIV-1, fleet-audit
339
+ // #1012). Every other error used to echo every identity on the host to the
340
+ // model, and spawn a `gog auth list`, for nothing.
341
+ if (!isAuthError && !isInvalidGrant) return errorResult(`${errText}${hint}`);
336
342
  try {
337
343
  const accounts = formatAccountList(await run(['auth', 'list']));
338
344
  return errorResult(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);