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.
@@ -1,167 +1,29 @@
1
- import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
- import { readEnvVar } from '@chrischall/mcp-utils';
1
+ import { createSpentTokenStore, type SpentTokenStore } from '@chrischall/mcp-utils';
3
2
 
4
3
  // ============================================================================
5
- // THE TOKEN FALLBACK for the Gmail dispatch rail (gmail-dispatch-guard.ts).
4
+ // THE TOKEN FALLBACK's gogcli-mcp half, which is now only its spent-token store.
5
+ // Everything else is the fleet's shared layer in @chrischall/mcp-utils
6
+ // (`confirmationFromEnv` + `requireConfirmationWithFallback`), configured by the
7
+ // same three variables as every other fleet server:
6
8
  //
7
- // A client with no MCP elicitation (claude.ai, measured) cannot be shown the
8
- // confirmation prompt, so every send is refused there. With
9
- // GOG_SEND_CONFIRM_FALLBACK=token the rail instead runs two phases: phase 1
10
- // returns the full preview plus a token and sends nothing; phase 2 presents the
11
- // token, the tool RE-READS what it would send, and dispatches only if that still
12
- // hashes to what the token was issued for.
9
+ // MCP_CONFIRM_MODE ask-user (default) | auto | refuse
10
+ // MCP_CONFIRM_TTL_SECONDS token lifetime, default 600
11
+ // MCP_CONFIRM_SECRET HMAC key; default random per process
13
12
  //
14
- // WHAT THIS DOES AND DOES NOT PROVE. Unlike elicitation, the approval here is a
15
- // tool argument, so the gate is the model honouring "show this to the user and
16
- // wait". The token cannot make the model ask; what it guarantees is that what
17
- // is sent matches what was previewed — a draft edited in a mail client (its
18
- // messageId rotates), a changed recipient or body, or a swapped attachment
19
- // between the two calls all refuse. Caller-supplied files are bound by content
20
- // hash; a stored draft's attachments by name and size, since Gmail's
21
- // attachment ids are unstable and any edit rotates the messageId anyway. One
22
- // approval acts once, for one tool, account and target, within the TTL — per
23
- // PROCESS: spent tokens live in memory, so with a shared GOG_CONFIRM_SECRET a
24
- // restart or a second instance would accept a spent token again until it
25
- // expires. That is why the mode is opt-in.
13
+ // WHAT THIS DOES AND DOES NOT PROVE: see mcp-utils' confirm-token module. The
14
+ // approval is a tool argument, so under ask-user the gate is the model honouring
15
+ // "show this to the user and wait"; the token guarantees what happens matches
16
+ // what was previewed, once, for one tool, account and target.
26
17
  // ============================================================================
27
18
 
28
- export type ConfirmTokenError = 'DRAFT_CHANGED' | 'TOKEN_EXPIRED' | 'TOKEN_REUSED' | 'TOKEN_INVALID';
19
+ const spent = createSpentTokenStore();
29
20
 
30
- /** What a token is bound to. Every field must match on phase 2. */
31
- export interface ConfirmBinding {
32
- tool: string;
33
- account: string;
34
- /** The draftId / messageId / query the dispatch acts on. */
35
- target: string;
36
- /** A version of the target that rotates on edit — a draft's messageId. */
37
- revision?: string;
38
- /** {@link hashSendPayload} of the canonical send payload. */
39
- payloadHash: string;
21
+ /** This server's spent-token store (one per process). */
22
+ export function confirmSpentStore(): SpentTokenStore {
23
+ return spent;
40
24
  }
41
25
 
42
- export type ConfirmTokenVerdict =
43
- | { ok: true }
44
- | { ok: false; error: ConfirmTokenError; reason?: 'payload-changed' | 'message-id-rotated' };
45
-
46
- export const CONFIRM_TOKEN_TTL_DEFAULT_SECONDS = 600;
47
-
48
- const PREFIX = 'gct1';
49
-
50
- type Claims = { t: string; a: string; g: string; r?: string; h: string; iat: number; exp: number; n: string };
51
-
52
- let secret: Buffer | undefined;
53
- // nonce → expiry (ms). Entries are dropped once they would have expired
54
- // anyway, so the set is bounded by the tokens spent within one TTL. In memory
55
- // only: see the header on GOG_CONFIRM_SECRET and restarts.
56
- const spent = new Map<string, number>();
57
-
58
- /** GOG_SEND_CONFIRM_FALLBACK=token turns the fallback on. Anything else is off. */
59
- export function sendConfirmFallbackEnabled(): boolean {
60
- return readEnvVar('GOG_SEND_CONFIRM_FALLBACK')?.trim().toLowerCase() === 'token';
61
- }
62
-
63
- export function confirmTokenTtlSeconds(): number {
64
- const raw = readEnvVar('GOG_CONFIRM_TTL_SECONDS')?.trim();
65
- if (raw && /^\d+$/.test(raw)) {
66
- const n = Number(raw);
67
- if (n > 0) return n;
68
- }
69
- return CONFIRM_TOKEN_TTL_DEFAULT_SECONDS;
70
- }
71
-
72
- // GOG_CONFIRM_SECRET ends in _SECRET, so runner.ts strips it from every gog
73
- // child — it never leaves this process.
74
- function hmacKey(): Buffer {
75
- if (!secret) {
76
- const configured = readEnvVar('GOG_CONFIRM_SECRET');
77
- secret = configured ? Buffer.from(configured, 'utf8') : randomBytes(32);
78
- }
79
- return secret;
80
- }
81
-
82
- /** Test seam: forget the secret (a "restart") and every spent token. */
26
+ /** Test seam: forget every spent token. */
83
27
  export function resetConfirmTokenState(): void {
84
- secret = undefined;
85
28
  spent.clear();
86
29
  }
87
-
88
- // Sorted keys, undefined dropped: the same payload re-read on phase 2 must
89
- // hash identically however its object was assembled.
90
- function canonicalize(value: unknown): unknown {
91
- if (Array.isArray(value)) return value.map(canonicalize);
92
- if (value && typeof value === 'object') {
93
- const out: Record<string, unknown> = {};
94
- for (const key of Object.keys(value as Record<string, unknown>).sort()) {
95
- const v = (value as Record<string, unknown>)[key];
96
- if (v !== undefined) out[key] = canonicalize(v);
97
- }
98
- return out;
99
- }
100
- return value;
101
- }
102
-
103
- /** SHA-256 (hex) of the canonical JSON of a send payload. */
104
- export function hashSendPayload(payload: unknown): string {
105
- return createHash('sha256').update(JSON.stringify(canonicalize(payload))).digest('hex');
106
- }
107
-
108
- function sign(body: string): string {
109
- return createHmac('sha256', hmacKey()).update(`${PREFIX}.${body}`).digest('base64url');
110
- }
111
-
112
- export function issueConfirmToken(binding: ConfirmBinding, now = Date.now()): { token: string; expiresAt: string } {
113
- const exp = now + confirmTokenTtlSeconds() * 1000;
114
- const claims: Claims = {
115
- t: binding.tool,
116
- a: binding.account,
117
- g: binding.target,
118
- ...(binding.revision === undefined ? {} : { r: binding.revision }),
119
- h: binding.payloadHash,
120
- iat: now,
121
- exp,
122
- n: randomBytes(16).toString('base64url'),
123
- };
124
- const body = Buffer.from(JSON.stringify(claims), 'utf8').toString('base64url');
125
- return { token: `${PREFIX}.${body}.${sign(body)}`, expiresAt: new Date(exp).toISOString() };
126
- }
127
-
128
- function signatureMatches(body: string, sig: string): boolean {
129
- const expected = Buffer.from(sign(body), 'utf8');
130
- const actual = Buffer.from(sig, 'utf8');
131
- return expected.length === actual.length && timingSafeEqual(expected, actual);
132
- }
133
-
134
- function parseClaims(body: string): Claims | undefined {
135
- try {
136
- return JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Claims;
137
- } catch {
138
- return undefined;
139
- }
140
- }
141
-
142
- /**
143
- * Check a phase-2 token against the binding recomputed from a fresh re-read.
144
- * Only an `ok` verdict consumes the token; a DRAFT_CHANGED leaves it unspent,
145
- * because the approval it carries is still true of the content it names.
146
- */
147
- export function verifyConfirmToken(token: string, binding: ConfirmBinding, now = Date.now()): ConfirmTokenVerdict {
148
- for (const [nonce, exp] of spent) if (exp < now) spent.delete(nonce);
149
-
150
- const parts = token.split('.');
151
- if (parts.length !== 3 || parts[0] !== PREFIX || !parts[1] || !parts[2]) return { ok: false, error: 'TOKEN_INVALID' };
152
- const [, body, sig] = parts as [string, string, string];
153
- if (!signatureMatches(body, sig)) return { ok: false, error: 'TOKEN_INVALID' };
154
- const claims = parseClaims(body);
155
- if (!claims) return { ok: false, error: 'TOKEN_INVALID' };
156
-
157
- if (claims.t !== binding.tool || claims.a !== binding.account || claims.g !== binding.target) {
158
- return { ok: false, error: 'TOKEN_INVALID' };
159
- }
160
- if (spent.has(claims.n)) return { ok: false, error: 'TOKEN_REUSED' };
161
- if (now > claims.exp) return { ok: false, error: 'TOKEN_EXPIRED' };
162
- if (claims.r !== binding.revision) return { ok: false, error: 'DRAFT_CHANGED', reason: 'message-id-rotated' };
163
- if (claims.h !== binding.payloadHash) return { ok: false, error: 'DRAFT_CHANGED', reason: 'payload-changed' };
164
-
165
- spent.set(claims.n, claims.exp);
166
- return { ok: true };
167
- }
package/src/tools/api.ts CHANGED
@@ -6,7 +6,7 @@ import { assertSafeForwardedArgs } from '../arg-guard.js';
6
6
  import { confineAtFile } from '../file-roots.js';
7
7
  import { pos } from '../argv.js';
8
8
  import type { GogArg } from '../runner.js';
9
- import { gatedElsewhere } from '../dispatch-confirmation.js';
9
+ import { BODY_PREVIEW_MAX, bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, requireDispatchConfirmation } from '../dispatch-confirmation.js';
10
10
 
11
11
  // Gmail methods gog_api_call refuses outright (audit SEC-2). `allowWrite` is a
12
12
  // boolean the MODEL sets, so it cannot stand in for the user's confirmation of
@@ -20,17 +20,38 @@ const GMAIL_API_BLOCKED = /(?:\.send$|forwarding|filters\.create|filters\.update
20
20
  // the dedicated tool asks the user, so the raw API method must not be a way
21
21
  // around it. Anchored on a `.` or the start so a Discovery id with the API
22
22
  // prefix (`chat.spaces.messages.create`) matches too.
23
+ // Fleet audit 2026-09-24 SEC-6 added the methods that notify someone, grant
24
+ // access or run code under the account's authority (moving an event with
25
+ // sendUpdates, roster adds, returning work, posting coursework, comments,
26
+ // send-as aliases, the vacation responder, member-seeded spaces, script runs).
23
27
  const DISPATCH_API_BLOCKED: Record<string, Array<{ method: RegExp; does: string; tool: string }>> = {
24
- chat: [{ method: /(?:^|\.)spaces\.messages\.create$/i, does: 'posts a Chat message', tool: 'gog_chat_messages_send / gog_chat_dm_send' }],
25
- drive: [{ method: /(?:^|\.)permissions\.(?:create|update)$/i, does: 'grants access to a file', tool: 'gog_drive_share' }],
28
+ chat: [
29
+ { method: /(?:^|\.)spaces\.messages\.create$/i, does: 'posts a Chat message', tool: 'gog_chat_messages_send / gog_chat_dm_send' },
30
+ { method: /(?:^|\.)spaces\.(?:setup|members\.create)$/i, does: 'adds people to a space', tool: 'gog_chat_spaces_create' },
31
+ ],
32
+ drive: [
33
+ { method: /(?:^|\.)permissions\.(?:create|update)$/i, does: 'grants access to a file', tool: 'gog_drive_share' },
34
+ { method: /(?:^|\.)comments\.create$/i, does: 'notifies the file\'s owner and anyone it mentions', tool: 'gog_drive_comments_add / gog_docs_comments_add' },
35
+ { method: /(?:^|\.)replies\.create$/i, does: 'notifies the comment thread', tool: 'gog_drive_comments_reply / gog_docs_comments_reply' },
36
+ ],
26
37
  classroom: [
27
38
  { method: /(?:^|\.)courses\.announcements\.create$/i, does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
28
39
  { method: /(?:^|\.)invitations\.create$/i, does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
40
+ { method: /(?:^|\.)courses\.students\.create$/i, does: 'adds someone to a class', tool: 'gog_classroom_students_add' },
41
+ { method: /(?:^|\.)courses\.teachers\.create$/i, does: 'gives someone teacher access to a class', tool: 'gog_classroom_teachers_add' },
42
+ { method: /(?:^|\.)courses\.coursework\.create$/i, does: 'posts coursework to a class', tool: 'gog_classroom_coursework_create' },
43
+ { method: /(?:^|\.)studentsubmissions\.return$/i, does: 'returns work to a student', tool: 'gog_classroom_submissions_return' },
29
44
  ],
30
45
  calendar: [
31
46
  { method: /(?:^|\.)events\.(?:insert|import|quickadd)$/i, does: 'can put an event on guests\' calendars', tool: 'gog_calendar_create' },
32
47
  { method: /(?:^|\.)events\.(?:update|patch)$/i, does: 'can change what guests see', tool: 'gog_calendar_update / gog_calendar_respond' },
48
+ { method: /(?:^|\.)events\.move$/i, does: 'can email every guest', tool: 'gog_calendar_move' },
33
49
  ],
50
+ gmail: [
51
+ { method: /(?:^|\.)settings\.sendas\.create$/i, does: 'makes Google email the address and adds a sending identity', tool: 'gog_gmail_sendas_create' },
52
+ { method: /(?:^|\.)settings\.updatevacation$/i, does: 'can turn on an auto-reply to every sender', tool: 'gog_gmail_vacation_update' },
53
+ ],
54
+ script: [{ method: /(?:^|\.)scripts\.run$/i, does: 'executes code with this account\'s authority', tool: 'gog_appscript_run_function' }],
34
55
  };
35
56
 
36
57
  export function refusedApiCall(api: string, method: string): string | undefined {
@@ -44,10 +65,25 @@ export function refusedApiCall(api: string, method: string): string | undefined
44
65
  return hit ? gatedElsewhere(`${name} ${m}`, 'gog_api_call', hit.does, hit.tool) : undefined;
45
66
  }
46
67
 
68
+ /**
69
+ * A params/body string as the confirmation prompt shows it: parsed, when it is
70
+ * JSON, so the user reads a structure rather than an escaped string; otherwise
71
+ * (an `@file` reference, a typo) the string itself. Bounded like every other
72
+ * body on the rail — the token binds the FULL string regardless.
73
+ */
74
+ export function previewJson(text: string): unknown {
75
+ if (text.length > BODY_PREVIEW_MAX) return bodyPreview(text);
76
+ try {
77
+ return JSON.parse(text);
78
+ } catch {
79
+ return text;
80
+ }
81
+ }
82
+
47
83
  // Generic Google Discovery API access (gog 0.31). gog_api_list / gog_api_describe
48
84
  // are read-only Discovery lookups; gog_api_call is a Discovery-backed escape
49
85
  // hatch for any method gog has no dedicated subcommand for — guarded by an
50
- // explicit write opt-in and a dry-run preview.
86
+ // explicit write opt-in, a dry-run preview, and the dispatch rail.
51
87
  export function registerApiTools(server: McpServer): void {
52
88
  server.registerTool('gog_api_list', {
53
89
  description: 'List the Google Discovery APIs available for gog_api_call / gog_api_describe (name + version + title).',
@@ -78,7 +114,7 @@ export function registerApiTools(server: McpServer): void {
78
114
  });
79
115
 
80
116
  server.registerTool('gog_api_call', {
81
- description: 'Call any Discovery-described Google API method directly — an escape hatch for endpoints gog has no dedicated tool for. Find the exact api/version/method/params with gog_api_describe first. Read methods (GET/LIST) run as-is. Mutating methods (POST/PUT/PATCH/DELETE) are refused unless you set allowWrite=true — keep it false to preview, or set dryRun=true to print the intended request without sending it. Gmail send and forwarding methods (users.messages.send, users.drafts.send, forwarding/auto-forwarding, filters, delegates) are refused — use the dedicated gog_gmail_* tools, which ask the user to confirm. So are the other methods a dedicated tool asks about: Chat spaces.messages.create, Drive permissions.create/update, Classroom courses.announcements.create and invitations.create, and Calendar events.insert/import/quickAdd/update/patch.',
117
+ description: 'Call any Discovery-described Google API method directly — an escape hatch for endpoints gog has no dedicated tool for. Find the exact api/version/method/params with gog_api_describe first. Read methods (GET/LIST) run as-is. Mutating methods (POST/PUT/PATCH/DELETE) are refused unless you set allowWrite=true, and every write then asks the MCP host to show the user a confirmation prompt with the exact api/version/method/params/body before anything is sent; set dryRun=true to print the intended request without sending it (no prompt, no changes). Gmail send and forwarding methods (users.messages.send, users.drafts.send, forwarding/auto-forwarding, filters, delegates) are refused outright — use the dedicated gog_gmail_* tools, which ask the user to confirm. So are the other methods a dedicated tool asks about with a better preview: Chat spaces.messages.create, Drive permissions.create/update, Classroom courses.announcements.create and invitations.create, and Calendar events.insert/import/quickAdd/update/patch.' + CONFIRM_FALLBACK_DESCRIPTION,
82
118
  annotations: { destructiveHint: true },
83
119
  inputSchema: z.object({
84
120
  api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
@@ -87,11 +123,12 @@ export function registerApiTools(server: McpServer): void {
87
123
  params: z.string().optional().describe('Query/path parameters as a JSON object string (e.g. {"fileId":"abc","fields":"name"})'),
88
124
  body: z.string().optional().describe('Request body as a JSON string (for write methods)'),
89
125
  scope: z.string().optional().describe('Override the OAuth scope used for the call'),
90
- allowWrite: z.boolean().optional().describe('Required to invoke a mutating method (POST/PUT/PATCH/DELETE). Without it, gog refuses write methods. Leave unset for read-only calls.'),
91
- dryRun: z.boolean().optional().describe('Print the intended request and exit without sending it (no changes made)'),
126
+ allowWrite: z.boolean().optional().describe('Required to invoke a mutating method (POST/PUT/PATCH/DELETE); the user is asked to confirm the exact request first. Without it, gog refuses write methods. Leave unset for read-only calls.'),
127
+ dryRun: z.boolean().optional().describe('Print the intended request and exit without sending it (no changes made, no confirmation prompt)'),
92
128
  account: accountParam,
129
+ confirmToken: confirmTokenParam,
93
130
  }),
94
- }, async ({ api, version, method, params, body, scope, allowWrite, dryRun, account }) => {
131
+ }, async ({ api, version, method, params, body, scope, allowWrite, dryRun, account, confirmToken }, ctx) => {
95
132
  try {
96
133
  assertSafeForwardedArgs([api, version, method]);
97
134
  const refusal = refusedApiCall(api, method);
@@ -103,6 +140,35 @@ export function registerApiTools(server: McpServer): void {
103
140
  } catch (err) {
104
141
  return errorResult(errorText(err));
105
142
  }
143
+ // SEC-2 (fleet-audit #931): allowWrite is a boolean the MODEL sets, and
144
+ // the --force below skips gog's own confirmation, so a method blocklist
145
+ // could never be complete (events.move/delete with sendUpdates=all,
146
+ // acl.insert, settings.sendAs.create, updateVacation, batchDelete, ...).
147
+ // Every write that will actually be sent asks the user first, about the
148
+ // exact api/method/params/body, through the same rail as the dedicated
149
+ // tools. A dry run sends nothing and is not gated; a write without
150
+ // allowWrite is refused by gog itself.
151
+ if (allowWrite && !dryRun) {
152
+ const request = { api, version, method, params, body, scope };
153
+ const view: Record<string, unknown> = { api, version, method };
154
+ if (params !== undefined) view.params = previewJson(params);
155
+ if (body !== undefined) view.body = previewJson(body);
156
+ if (scope !== undefined) view.scope = scope;
157
+ const confirmation = await requireDispatchConfirmation(ctx, {
158
+ action: 'api.call',
159
+ message: `Review and confirm this raw Google API write (${api} ${version} ${method}):`,
160
+ confirmationLabel: 'Confirm that this API method should be invoked with exactly these parameters.',
161
+ details: view,
162
+ unsupportedNote: 'Set dryRun=true to see the request gog would send without sending it, or use the dedicated gog_* tool for this operation.',
163
+ fallback: {
164
+ tool: 'gog_api_call',
165
+ account,
166
+ confirmToken,
167
+ subject: () => ({ target: `${api}/${version}/${method}`, payload: request, preview: view }),
168
+ },
169
+ });
170
+ if (confirmation) return confirmation;
171
+ }
106
172
  const args: GogArg[] = ['api', 'call', pos(api), pos(version), pos(method)];
107
173
  if (params) args.push(`--params=${params}`);
108
174
  if (body) args.push(`--body=${body}`);
@@ -10,6 +10,41 @@ import {
10
10
  import { pos } from '../argv.js';
11
11
  import { confinePath } from '../file-roots.js';
12
12
  import type { GogArg } from '../runner.js';
13
+ import {
14
+ CONFIRM_FALLBACK_DESCRIPTION,
15
+ confirmTokenParam,
16
+ gatedElsewhere,
17
+ requireDispatchConfirmation,
18
+ resultText,
19
+ senderPreview,
20
+ } from '../dispatch-confirmation.js';
21
+
22
+ /** gog_appscript_run must not execute code that gog_appscript_run_function would ask about. */
23
+ export function vetAppScriptRun(subcommand: string, _args: readonly string[]): string | undefined {
24
+ return subcommand.toLowerCase() === 'run'
25
+ ? gatedElsewhere('gog appscript run', 'gog_appscript_run', 'executes code with this account\'s authority', 'gog_appscript_run_function')
26
+ : undefined;
27
+ }
28
+
29
+ /**
30
+ * The project a run executes, for its confirmation prompt: a title the user
31
+ * recognises beside the opaque id. `updateTime` is kept apart as the token's
32
+ * revision, so code saved between the two phases is DRAFT_CHANGED. Unreadable
33
+ * output names nothing rather than throwing.
34
+ */
35
+ export function projectSnapshot(raw: string, scriptId: string): { scriptId: string; title?: string; updateTime?: string } {
36
+ let project: { title?: unknown; updateTime?: unknown } | undefined;
37
+ try {
38
+ project = (JSON.parse(raw) as { project?: typeof project } | null)?.project;
39
+ } catch {
40
+ project = undefined;
41
+ }
42
+ return {
43
+ scriptId,
44
+ ...(typeof project?.title === 'string' ? { title: project.title } : {}),
45
+ ...(typeof project?.updateTime === 'string' ? { updateTime: project.updateTime } : {}),
46
+ };
47
+ }
13
48
 
14
49
  // Google Apps Script (gog >= 0.38.0 for pull/deployments/versions).
15
50
  //
@@ -140,7 +175,9 @@ export function registerAppScriptTools(server: McpServer): void {
140
175
  + 'Requires the project to be deployed as an API executable and to share the OAuth client with the calling '
141
176
  + 'credentials, otherwise Google refuses regardless of scopes. devMode runs the latest saved code instead of the '
142
177
  + 'deployed version, and only works if the account owns the script. '
143
- + 'This is NOT the escape hatch — gog_appscript_run is that.' + apiEnableNote,
178
+ + 'This is NOT the escape hatch — gog_appscript_run is that. Because the wrapper cannot tell what the code will '
179
+ + 'do, it reads the project and asks the MCP host to show the user a confirmation prompt with the project, the '
180
+ + 'function and its arguments first; nothing runs unless they accept.' + CONFIRM_FALLBACK_DESCRIPTION + apiEnableNote,
144
181
  annotations: { destructiveHint: true },
145
182
  inputSchema: z.object({
146
183
  scriptId: scriptIdParam,
@@ -148,12 +185,14 @@ export function registerAppScriptTools(server: McpServer): void {
148
185
  params: z.string().optional().describe('Function parameters as a JSON ARRAY of positional arguments, e.g. \'["a", 1]\' — not an object'),
149
186
  devMode: z.boolean().optional().describe('Run the latest saved code rather than the deployed version (owner only)'),
150
187
  account: accountParam,
188
+ confirmToken: confirmTokenParam,
151
189
  }),
152
- }, async ({ scriptId, functionName, params, devMode, account }) => {
190
+ }, async ({ scriptId, functionName, params, devMode, account, confirmToken }, ctx) => {
153
191
  // gog passes --params through to the API as-is, so a malformed value comes
154
192
  // back as a Google error about the request body rather than about the
155
193
  // argument the caller actually got wrong. Checking the shape here is what
156
194
  // turns "invalid argument" into "params must be a JSON array".
195
+ let parsedParams: unknown[] | undefined;
157
196
  if (params !== undefined) {
158
197
  let parsed: unknown;
159
198
  try {
@@ -164,7 +203,28 @@ export function registerAppScriptTools(server: McpServer): void {
164
203
  if (!Array.isArray(parsed)) {
165
204
  throw new Error(`params must be a JSON ARRAY of positional arguments, e.g. '["a", 1]' — Apps Script takes positional arguments, not named ones. Received: ${params}`);
166
205
  }
206
+ parsedParams = parsed;
167
207
  }
208
+ // Read on every call: the prompt names the project, and on the token
209
+ // fallback's phase 2 this is the re-read the token is checked against.
210
+ const got = await runOrDiagnose(['appscript', 'get', pos(scriptId)], { account });
211
+ if (got.isError) return got;
212
+ const { updateTime, ...project } = projectSnapshot(resultText(got), scriptId);
213
+ const execution = { project, functionName, params: parsedParams, devMode: Boolean(devMode), runsAs: senderPreview(account) };
214
+ const confirmation = await requireDispatchConfirmation(ctx, {
215
+ action: 'appscript.run-function',
216
+ message: 'Review and confirm running this Apps Script function with the account\'s full authority:',
217
+ confirmationLabel: 'Confirm that this code should run now.',
218
+ details: execution,
219
+ unsupportedNote: 'Ask the user to run it themselves from the Apps Script editor.',
220
+ fallback: {
221
+ tool: 'gog_appscript_run_function',
222
+ account,
223
+ confirmToken,
224
+ subject: () => ({ target: `${scriptId}/${functionName}`, revision: updateTime, payload: execution, preview: execution }),
225
+ },
226
+ });
227
+ if (confirmation) return confirmation;
168
228
  const args: GogArg[] = ['appscript', 'run', pos(scriptId), pos(functionName)];
169
229
  if (params !== undefined) args.push(`--params=${params}`);
170
230
  if (devMode) args.push('--dev-mode');
@@ -174,6 +234,7 @@ export function registerAppScriptTools(server: McpServer): void {
174
234
  registerRunTool(server, {
175
235
  service: 'appscript',
176
236
  examples: '"get", "content", "deployments"',
237
+ vet: vetAppScriptRun,
177
238
  note: 'To execute a function, use gog_appscript_run_function — this tool is the generic escape hatch.',
178
239
  });
179
240
  }
@@ -5,22 +5,60 @@ import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAlias
5
5
  import { annotateTruncatedList } from '../pagination.js';
6
6
  import { pos } from '../argv.js';
7
7
  import type { GogArg } from '../runner.js';
8
- import { CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
8
+ import { CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, flagValue, gatedElsewhere, refusedInRun, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
9
9
 
10
- // gog's spellings (internal/cmd/calendar.go). The run tool cannot tell whether
11
- // an event has guests, so it refuses these outright; the dedicated tools ask
12
- // only when someone else would see the change.
13
- const CALENDAR_GATED: Record<string, string> = {
14
- create: 'gog_calendar_create', add: 'gog_calendar_create', new: 'gog_calendar_create',
15
- update: 'gog_calendar_update', edit: 'gog_calendar_update', set: 'gog_calendar_update',
16
- respond: 'gog_calendar_respond', rsvp: 'gog_calendar_respond', reply: 'gog_calendar_respond',
10
+ // gog's spellings (internal/cmd/calendar.go; aliases per `gog schema` 0.41.0).
11
+ // The run tool cannot tell whether an event has guests, so it refuses these
12
+ // outright; the dedicated tools ask only when someone else would see the
13
+ // change. Move, delete, delete-calendar and out-of-office joined in the fleet
14
+ // audit of 2026-09-24 (SEC-4 #933, SEC-6): --send-updates on a move emails
15
+ // every guest, out-of-office auto-declines conflicting invitations by default,
16
+ // and delete's --scope defaults to the whole recurring series.
17
+ const ASKS = (tool: string) => `Use ${tool}, which asks the user to confirm.`;
18
+ const CALENDAR_REFUSED: Record<string, { does: string; instead: string }> = {
19
+ create: { does: 'can change what guests see', instead: ASKS('gog_calendar_create') },
20
+ add: { does: 'can change what guests see', instead: ASKS('gog_calendar_create') },
21
+ new: { does: 'can change what guests see', instead: ASKS('gog_calendar_create') },
22
+ update: { does: 'can change what guests see', instead: ASKS('gog_calendar_update') },
23
+ edit: { does: 'can change what guests see', instead: ASKS('gog_calendar_update') },
24
+ set: { does: 'can change what guests see', instead: ASKS('gog_calendar_update') },
25
+ respond: { does: 'can change what guests see', instead: ASKS('gog_calendar_respond') },
26
+ rsvp: { does: 'can change what guests see', instead: ASKS('gog_calendar_respond') },
27
+ reply: { does: 'can change what guests see', instead: ASKS('gog_calendar_respond') },
28
+ move: { does: "moves an event off guests' calendars (and can email them)", instead: ASKS('gog_calendar_move') },
29
+ transfer: { does: "moves an event off guests' calendars (and can email them)", instead: ASKS('gog_calendar_move') },
30
+ delete: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS('gog_calendar_delete') },
31
+ del: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS('gog_calendar_delete') },
32
+ rm: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS('gog_calendar_delete') },
33
+ remove: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS('gog_calendar_delete') },
34
+ 'delete-calendar': { does: 'deletes a calendar and every event on it', instead: ASKS('gog_calendar_delete_calendar') },
35
+ 'out-of-office': { does: 'auto-declines invitations and notifies their organizers', instead: ASKS('gog_calendar_out_of_office') },
36
+ ooo: { does: 'auto-declines invitations and notifies their organizers', instead: ASKS('gog_calendar_out_of_office') },
17
37
  };
18
38
 
19
39
  /** gog_calendar_run must not make the changes gog_calendar_create/update/respond would ask about. */
20
- export function vetCalendarRun(subcommand: string, _args: readonly string[]): string | undefined {
40
+ export function vetCalendarRun(subcommand: string, args: readonly string[]): string | undefined {
21
41
  const sub = subcommand.toLowerCase();
22
- const tool = Object.hasOwn(CALENDAR_GATED, sub) ? CALENDAR_GATED[sub] : undefined;
23
- return tool ? gatedElsewhere(`gog calendar ${sub}`, 'gog_calendar_run', 'can change what guests see', tool) : undefined;
42
+ const what = `gog calendar ${sub}`;
43
+ const via = 'gog_calendar_run';
44
+ if (Object.hasOwn(CALENDAR_REFUSED, sub)) {
45
+ const { does, instead } = CALENDAR_REFUSED[sub]!;
46
+ return refusedInRun(what, via, does, instead);
47
+ }
48
+ // `propose-time` alone generates a URL. With --decline (or --comment, which
49
+ // implies it) it declines the event and notifies the organizer — exactly
50
+ // what gog_calendar_respond asks about (SEC-4, fleet-audit #933).
51
+ if (sub === 'propose-time' && (flagValue(args, 'decline') !== undefined || flagValue(args, 'comment') !== undefined)) {
52
+ return gatedElsewhere(`${what} --decline`, via, 'declines the event and notifies the organizer', 'gog_calendar_respond');
53
+ }
54
+ // A Focus Time block auto-declines every conflicting invitation by default
55
+ // (gog's --auto-decline defaults to all) and there is no dedicated tool for
56
+ // it; one that declines nobody is a private block and passes.
57
+ if ((sub === 'focus-time' || sub === 'focus') && flagValue(args, 'auto-decline')?.toLowerCase() !== 'none') {
58
+ return refusedInRun(what, via, 'auto-declines invitations and notifies their organizers (gog defaults --auto-decline to all)',
59
+ 'Pass --auto-decline=none, or ask the user to create it from Google Calendar.');
60
+ }
61
+ return undefined;
24
62
  }
25
63
 
26
64
  // Reminder params, shared by create and update (gog >= 0.38.0 for
@@ -70,11 +108,13 @@ export function eventSnapshot(raw: string): {
70
108
  end?: string;
71
109
  organizer?: string;
72
110
  guests: string[];
111
+ recurring?: true;
73
112
  etag?: string;
74
113
  } {
75
114
  let event: {
76
115
  summary?: unknown; start?: EventTime; end?: EventTime; etag?: unknown;
77
116
  organizer?: { email?: unknown }; attendees?: EventAttendee[];
117
+ recurrence?: unknown; recurringEventId?: unknown;
78
118
  } | undefined;
79
119
  try {
80
120
  event = (JSON.parse(raw) as { event?: typeof event } | null)?.event;
@@ -91,6 +131,10 @@ export function eventSnapshot(raw: string): {
91
131
  guests: (Array.isArray(event?.attendees) ? event.attendees : [])
92
132
  .filter((a) => a.self !== true && a.resource !== true && typeof a.email === 'string')
93
133
  .map((a) => a.email as string),
134
+ // A series, or one instance of one: gog's delete --scope defaults to all.
135
+ ...((Array.isArray(event?.recurrence) && event.recurrence.length > 0) || typeof event?.recurringEventId === 'string'
136
+ ? { recurring: true as const }
137
+ : {}),
94
138
  etag: str(event?.etag),
95
139
  };
96
140
  }
@@ -357,14 +401,40 @@ export function registerCalendarTools(server: McpServer): void {
357
401
  });
358
402
 
359
403
  server.registerTool('gog_calendar_delete', {
360
- description: 'Delete a calendar event.',
404
+ description: 'Delete a calendar event. It disappears from every guest\'s calendar too, and for a recurring event gog '
405
+ + 'deletes the WHOLE series, so when the event has guests or recurs this reads it and asks the MCP host to show '
406
+ + 'the user a confirmation prompt with the event as it stands; a guest-free, one-off event is deleted without asking.'
407
+ + CONFIRM_FALLBACK_DESCRIPTION,
361
408
  annotations: { destructiveHint: true },
362
409
  inputSchema: z.object({
363
410
  calendarId: z.string().describe('Calendar ID'),
364
411
  eventId: z.string().describe('Event ID'),
365
412
  account: accountParam,
413
+ confirmToken: confirmTokenParam,
366
414
  }),
367
- }, async ({ calendarId, eventId, account }) => {
415
+ }, async ({ calendarId, eventId, account, confirmToken }, ctx) => {
416
+ const read = await readEvent(calendarId, eventId, account);
417
+ if (read.error) return read.error;
418
+ const { etag, ...event } = read.event;
419
+ if (event.guests.length > 0 || event.recurring) {
420
+ const view = { calendarId, eventId, event, scope: event.recurring ? 'the whole recurring series' : 'this event' };
421
+ const confirmation = await requireDispatchConfirmation(ctx, {
422
+ action: 'calendar.delete',
423
+ message: event.recurring
424
+ ? 'Review and confirm deleting this ENTIRE recurring series:'
425
+ : 'Review and confirm deleting this event from every guest\'s calendar:',
426
+ confirmationLabel: 'Confirm that this event should be deleted now.',
427
+ details: view,
428
+ unsupportedNote: 'Ask the user to delete it from Google Calendar.',
429
+ fallback: {
430
+ tool: 'gog_calendar_delete',
431
+ account,
432
+ confirmToken,
433
+ subject: () => ({ target: `${calendarId}/${eventId}`, revision: etag, payload: view, preview: view }),
434
+ },
435
+ });
436
+ if (confirmation) return confirmation;
437
+ }
368
438
  // gog gates this delete behind a confirmation; the runner injects
369
439
  // --no-input, so without --force it refuses at runtime.
370
440
  return runOrDiagnose(['calendar', 'delete', pos(calendarId), pos(eventId), '--force'], { account });
package/src/tools/chat.ts CHANGED
@@ -27,9 +27,20 @@ import {
27
27
  // gog's spellings of `send` under `messages` and `dm` (internal/cmd/chat_messages.go, chat_dm.go).
28
28
  const CHAT_SEND_WORDS = new Set(['send', 'create', 'post']);
29
29
 
30
- /** gog_chat_run must not post what gog_chat_messages_send / gog_chat_dm_send would ask about. */
30
+ // gog's spellings of `spaces create` (`gog schema` 0.41.0).
31
+ const CHAT_SPACE_CREATE_WORDS = new Set(['create', 'add', 'new']);
32
+
33
+ /** gog_chat_run must not post, or add people to a space, where the dedicated tools would ask. */
31
34
  export function vetChatRun(subcommand: string, args: readonly string[]): string | undefined {
32
35
  const sub = subcommand.toLowerCase();
36
+ if (sub === 'spaces') {
37
+ // A member-less space reaches nobody; one seeded with --member adds and notifies them.
38
+ const word = hasCommandWord(args, CHAT_SPACE_CREATE_WORDS);
39
+ const withMembers = args.some((a) => /^--members?(?:=|$)/i.test(a));
40
+ return word && withMembers
41
+ ? gatedElsewhere(`gog chat spaces ${word.toLowerCase()} --member`, 'gog_chat_run', 'adds people to a space', 'gog_chat_spaces_create')
42
+ : undefined;
43
+ }
33
44
  if (sub !== 'messages' && sub !== 'dm') return undefined;
34
45
  const word = hasCommandWord(args, CHAT_SEND_WORDS);
35
46
  if (!word) return undefined;
@@ -114,17 +125,37 @@ export function registerChatTools(server: McpServer): void {
114
125
 
115
126
  server.registerTool('gog_chat_spaces_create', {
116
127
  description:
117
- 'Create a named Chat space, optionally seeding its membership. Members are added immediately and are notified — this '
118
- + 'is visible to other people the moment it runs, so confirm the member list before calling it.' + workspaceOnlyNote,
128
+ 'Create a named Chat space, optionally seeding its membership. Members are added immediately and are notified, so '
129
+ + 'with members this asks the MCP host to show the user a confirmation prompt with the space name and every member '
130
+ + 'first; nothing is created unless they accept. A space with no members reaches nobody and is created without '
131
+ + 'asking.' + CONFIRM_FALLBACK_DESCRIPTION + workspaceOnlyNote,
119
132
  annotations: { destructiveHint: true },
120
133
  inputSchema: z.object({
121
134
  displayName: z.string().describe('Display name for the new space'),
122
135
  members: z.array(z.string()).optional().describe('Initial members, as email addresses or "users/..." resource names'),
123
136
  account: accountParam,
137
+ confirmToken: confirmTokenParam,
124
138
  }),
125
- }, async ({ displayName, members, account }) => {
139
+ }, async ({ displayName, members, account, confirmToken }, ctx) => {
126
140
  const args: GogArg[] = ['chat', 'spaces', 'create', pos(displayName)];
127
141
  if (members) for (const member of members) args.push(`--member=${member}`);
142
+ if (members?.length) {
143
+ const space = { displayName, members };
144
+ const confirmation = await requireDispatchConfirmation(ctx, {
145
+ action: 'chat.space-create',
146
+ message: 'Review and confirm this Chat space — every member is added and notified:',
147
+ confirmationLabel: 'Confirm that these people should be added to a new space now.',
148
+ details: space,
149
+ unsupportedNote: 'Create the space without members instead; the user can add people from Google Chat.',
150
+ fallback: {
151
+ tool: 'gog_chat_spaces_create',
152
+ account,
153
+ confirmToken,
154
+ subject: () => ({ target: displayName, payload: space, preview: space }),
155
+ },
156
+ });
157
+ if (confirmation) return confirmation;
158
+ }
128
159
  return runOrDiagnose(args, { account });
129
160
  });
130
161