gogcli-mcp 4.3.0 → 4.4.0

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/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp",
5
5
  "display_name": "gogcli",
6
- "version": "4.3.0",
6
+ "version": "4.4.0",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp",
5
5
  "description": "MCP server wrapping gogcli for Google service access",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "4.3.0",
10
+ "version": "4.4.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "4.3.0",
15
+ "version": "4.4.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -0,0 +1,277 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
4
+ import { callerAcceptsFormElicitation, readEnvVar, requireConfirmation, textResult } from '@chrischall/mcp-utils';
5
+ import { z } from 'zod';
6
+ import {
7
+ confirmTokenTtlSeconds,
8
+ hashSendPayload,
9
+ issueConfirmToken,
10
+ sendConfirmFallbackEnabled,
11
+ verifyConfirmToken,
12
+ type ConfirmBinding,
13
+ type ConfirmTokenError,
14
+ } from './send-confirm-token.js';
15
+
16
+ // ============================================================================
17
+ // THE DISPATCH RAIL, service-neutral: every tool that reaches another person
18
+ // (Gmail sends, Chat posts, guest-visible Calendar changes, Drive shares,
19
+ // Classroom announcements and invitations) asks the user first.
20
+ //
21
+ // Elicitation is primary: the host shows the preview and the model never holds
22
+ // the approval. On a client that declares no elicitation (claude.ai, measured),
23
+ // the call is refused — unless the call site supplies a `DispatchTokenFallback`
24
+ // and the server opted in with GOG_SEND_CONFIRM_FALLBACK=token, in which case
25
+ // the two-phase preview + confirmToken flow (send-confirm-token.ts) runs instead.
26
+ // ============================================================================
27
+
28
+ // Bound on the body text shown in a confirmation prompt. Enough to read what is
29
+ // actually being sent (the point of SEC-5), small enough to keep the prompt a
30
+ // prompt rather than a copy of the message.
31
+ export const BODY_PREVIEW_MAX = 2048;
32
+
33
+ /** The first BODY_PREVIEW_MAX characters of a body, marked when cut. */
34
+ export function bodyPreview(text: string | undefined): string | undefined {
35
+ if (!text) return undefined;
36
+ if (text.length <= BODY_PREVIEW_MAX) return text;
37
+ return `${text.slice(0, BODY_PREVIEW_MAX)}… [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
38
+ }
39
+
40
+ /**
41
+ * Every attachment a dispatch will carry: a server path in full (so the user
42
+ * sees WHICH file on the gog host is leaving), an inline one by its filename.
43
+ */
44
+ export function attachmentNames(
45
+ paths: readonly string[] | undefined,
46
+ inline: ReadonlyArray<{ filename: string }> | undefined,
47
+ ): string[] {
48
+ return [...(paths ?? []), ...(inline ?? []).map((a) => a.filename)];
49
+ }
50
+
51
+ /** One attachment as the fallback preview shows it and its hash binds it. */
52
+ export type AttachmentDetail = { name: string; size: number | null; sha256?: string };
53
+
54
+ /**
55
+ * Name, byte size and content SHA-256 of every file a dispatch carries, for the
56
+ * token fallback's preview and payload hash. A server path is read (it is
57
+ * already confined to GOG_FILE_ROOTS) so a same-size swap between the phases is
58
+ * still a changed payload; an unreadable one reports `size: null` and gog will
59
+ * fail on it anyway.
60
+ */
61
+ export function attachmentDetails(
62
+ paths: readonly string[] | undefined,
63
+ inline: ReadonlyArray<{ filename: string; contentBase64: string }> | undefined,
64
+ ): AttachmentDetail[] {
65
+ const out: AttachmentDetail[] = [];
66
+ for (const path of paths ?? []) {
67
+ let bytes: Buffer | undefined;
68
+ try {
69
+ bytes = readFileSync(path);
70
+ } catch {
71
+ bytes = undefined;
72
+ }
73
+ out.push(bytes
74
+ ? { name: path, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }
75
+ : { name: path, size: null });
76
+ }
77
+ for (const a of inline ?? []) {
78
+ out.push({
79
+ name: a.filename,
80
+ size: Buffer.from(a.contentBase64, 'base64').length,
81
+ sha256: createHash('sha256').update(a.contentBase64).digest('hex'),
82
+ });
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** Who a dispatch goes out as, for the fallback preview: an explicit alias, else the account. */
88
+ export function senderPreview(account: string | undefined, from?: string): string {
89
+ return from ?? account ?? readEnvVar('GOG_ACCOUNT') ?? "the gog account's default address";
90
+ }
91
+
92
+ /** Drop the fingerprint for display: the user needs a name and a size. */
93
+ export function attachmentPreview(details: readonly AttachmentDetail[]): Array<{ name: string; size: number | null }> {
94
+ return details.map(({ name, size }) => ({ name, size }));
95
+ }
96
+
97
+ /** The instruction a mail dispatch's phase 1 carries — verbatim from the spec that introduced the fallback. */
98
+ export const CONFIRM_SEND_INSTRUCTION =
99
+ 'Show this preview to the user verbatim and send only after they explicitly approve in chat. '
100
+ + 'Then call again with confirmToken.';
101
+
102
+ /** The same instruction for a dispatch that is not mail (a share, an invitation, a post). */
103
+ export const CONFIRM_ACTION_INSTRUCTION =
104
+ 'Show this preview to the user verbatim and proceed only after they explicitly approve in chat. '
105
+ + 'Then call again with confirmToken.';
106
+
107
+ const FALLBACK_HINT = 'Or set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation.';
108
+
109
+ /** Appended to each gated tool's description. */
110
+ export const CONFIRM_FALLBACK_DESCRIPTION =
111
+ ' If the client cannot show that prompt (no MCP elicitation, e.g. claude.ai) and the server sets '
112
+ + 'GOG_SEND_CONFIRM_FALLBACK=token, a two-step flow applies instead: call WITHOUT confirmToken and nothing is '
113
+ + 'sent or changed — the result has status "confirmation-required", the full preview and a confirmToken. Show that preview '
114
+ + 'to the user verbatim; only after they explicitly approve it in chat, call again with the SAME arguments plus '
115
+ + 'confirmToken. The tool re-reads what it would act on and refuses (DRAFT_CHANGED, with a fresh preview and token) '
116
+ + 'if it changed; TOKEN_EXPIRED / TOKEN_REUSED / TOKEN_INVALID also do nothing.';
117
+
118
+ export const confirmTokenParam = z.string().optional().describe(
119
+ 'ONLY for the two-step fallback (client without MCP elicitation, server with GOG_SEND_CONFIRM_FALLBACK=token). '
120
+ + 'The confirmToken from this same tool\'s phase-1 "confirmation-required" response, passed back ONLY after the user '
121
+ + 'has seen that preview and explicitly approved it in chat — never on the first call, never invented, never '
122
+ + 'reused. Call again with the same arguments. Ignored when the client supports elicitation.',
123
+ );
124
+
125
+ /** What the fallback binds a token to — recomputed from a fresh read on every call. */
126
+ export interface TokenSubject {
127
+ /** The draftId / messageId / fileId / eventId / query the dispatch acts on. */
128
+ target: string;
129
+ /** A version that rotates on edit: a draft's messageId, an event's etag. */
130
+ revision?: string;
131
+ /** Canonical send payload; its SHA-256 is bound into the token. */
132
+ payload: unknown;
133
+ /** The complete preview shown to the user. */
134
+ preview: Record<string, unknown>;
135
+ }
136
+
137
+ /**
138
+ * Opt-in second rail for a client that cannot be prompted. `subject` is only
139
+ * called when the fallback actually runs, so a tool may do an extra read there
140
+ * without changing the elicitation path at all. It may return an error result
141
+ * (a failed read), which is passed back unchanged.
142
+ */
143
+ export interface DispatchTokenFallback {
144
+ tool: string;
145
+ account?: string;
146
+ confirmToken?: string;
147
+ /** Phase 1's instruction to the model. Defaults to {@link CONFIRM_ACTION_INSTRUCTION}. */
148
+ instruction?: string;
149
+ subject: () => TokenSubject | CallToolResult | Promise<TokenSubject | CallToolResult>;
150
+ }
151
+
152
+ const TOKEN_ERROR_NOTE: Record<Exclude<ConfirmTokenError, 'DRAFT_CHANGED'>, string> = {
153
+ TOKEN_EXPIRED: 'Nothing was sent or changed: the confirmToken expired. Call again WITHOUT confirmToken for a fresh preview, '
154
+ + 'and ask the user to approve it again.',
155
+ TOKEN_REUSED: 'Nothing was sent or changed by this call: this confirmToken was already used, and one approval acts once. '
156
+ + 'If doing it again is really intended, call again WITHOUT confirmToken and get a new approval.',
157
+ TOKEN_INVALID: 'Nothing was sent or changed: this confirmToken was not issued by this server for this tool, account and '
158
+ + 'target (or the server has restarted since). Call again WITHOUT confirmToken for a fresh preview and approval.',
159
+ };
160
+
161
+ const DRAFT_CHANGED_NOTE = {
162
+ 'message-id-rotated': 'Nothing was sent or changed: the target was edited since the user approved it (a draft\'s '
163
+ + 'messageId or an event\'s version rotated), so what would happen is not what they saw.',
164
+ 'payload-changed': 'Nothing was sent or changed: what would happen no longer matches what the user approved.',
165
+ } as const;
166
+
167
+ function isToolResult(value: TokenSubject | CallToolResult): value is CallToolResult {
168
+ return Array.isArray((value as CallToolResult).content);
169
+ }
170
+
171
+ function rejection(data: Record<string, unknown>): CallToolResult {
172
+ return { ...textResult({ status: 'confirmation-rejected', confirmed: false, dispatched: false, ...data }), isError: true };
173
+ }
174
+
175
+ async function tokenConfirmation(op: string, fallback: DispatchTokenFallback): Promise<CallToolResult | undefined> {
176
+ const subject = await fallback.subject();
177
+ if (isToolResult(subject)) return subject;
178
+ const binding: ConfirmBinding = {
179
+ tool: fallback.tool,
180
+ account: fallback.account ?? readEnvVar('GOG_ACCOUNT') ?? '',
181
+ target: subject.target,
182
+ ...(subject.revision === undefined ? {} : { revision: subject.revision }),
183
+ payloadHash: hashSendPayload(subject.payload),
184
+ };
185
+ const phaseOne = () => {
186
+ const { token, expiresAt } = issueConfirmToken(binding);
187
+ return {
188
+ action: op,
189
+ preview: subject.preview,
190
+ confirmToken: token,
191
+ expiresAt,
192
+ ttlSeconds: confirmTokenTtlSeconds(),
193
+ instruction: fallback.instruction ?? CONFIRM_ACTION_INSTRUCTION,
194
+ };
195
+ };
196
+ if (!fallback.confirmToken) {
197
+ return textResult({ status: 'confirmation-required', confirmed: false, dispatched: false, ...phaseOne() });
198
+ }
199
+ const verdict = verifyConfirmToken(fallback.confirmToken, binding);
200
+ if (verdict.ok) return undefined;
201
+ if (verdict.error === 'DRAFT_CHANGED') {
202
+ return rejection({
203
+ error: 'DRAFT_CHANGED',
204
+ reason: verdict.reason,
205
+ note: `${DRAFT_CHANGED_NOTE[verdict.reason!]} The current preview and a fresh confirmToken are below.`,
206
+ ...phaseOne(),
207
+ });
208
+ }
209
+ return rejection({ error: verdict.error, action: op, note: TOKEN_ERROR_NOTE[verdict.error] });
210
+ }
211
+
212
+ /**
213
+ * The refusal an escape hatch (`gog_<service>_run`, `gog_api_call`) gives for an
214
+ * action a dedicated tool gates. Without it, the run tool is a way around the
215
+ * rail: the model forwards the same subcommand and nobody is asked (#400).
216
+ */
217
+ export function gatedElsewhere(what: string, via: string, does: string, tool: string): string {
218
+ return `${what} ${does} and is not available through ${via}. Use ${tool}, which asks the user to confirm.`;
219
+ }
220
+
221
+ /** True when any forwarded token is one of `words` (kong lets flags precede the command word). */
222
+ export function hasCommandWord(args: readonly string[], words: ReadonlySet<string>): string | undefined {
223
+ return args.find((a) => words.has(a.toLowerCase()));
224
+ }
225
+
226
+ export interface DispatchConfirmationOptions {
227
+ /** Stable id of the dispatch, echoed in every result (`gmail.send`, `drive.share`, …). */
228
+ action: string;
229
+ /** Heading of the elicitation prompt. */
230
+ message: string;
231
+ /** Label beside the prompt's confirmation checkbox. */
232
+ confirmationLabel: string;
233
+ /** What the elicitation prompt shows. */
234
+ details: Record<string, unknown>;
235
+ /** The way through on a client that cannot be prompted, when there is one. */
236
+ unsupportedNote?: string;
237
+ /** Opt-in second rail; omit it and a client that cannot be prompted is always refused. */
238
+ fallback?: DispatchTokenFallback;
239
+ }
240
+
241
+ /**
242
+ * Ask the user before a dispatch. `undefined` means proceed; anything else is
243
+ * the result to return unchanged.
244
+ *
245
+ * Elicitation stays the primary path and is untouched. Only when the caller
246
+ * declares it cannot be prompted AND a `fallback` is supplied AND
247
+ * GOG_SEND_CONFIRM_FALLBACK=token does the two-phase token flow run instead of
248
+ * the refusal; with the env unset, the refusal names that switch.
249
+ */
250
+ export async function requireDispatchConfirmation(
251
+ ctx: ServerContext,
252
+ options: DispatchConfirmationOptions,
253
+ ): Promise<InputRequiredResult | CallToolResult | undefined> {
254
+ const { action, fallback } = options;
255
+ if (fallback && callerAcceptsFormElicitation(ctx) === false && sendConfirmFallbackEnabled()) {
256
+ return tokenConfirmation(action, fallback);
257
+ }
258
+ const note = fallback
259
+ ? [options.unsupportedNote, FALLBACK_HINT].filter(Boolean).join(' ')
260
+ : options.unsupportedNote;
261
+ return requireConfirmation(ctx, {
262
+ action,
263
+ message: options.message,
264
+ details: options.details,
265
+ confirmationLabel: options.confirmationLabel,
266
+ ...(note ? { unsupportedNote: note } : {}),
267
+ });
268
+ }
269
+
270
+ // The single place a CallToolResult's text is pulled back out, for the tools
271
+ // here that need to read gog's own JSON before deciding what to preview or
272
+ // log. Mirrors the shape every runOrDiagnose result actually returns
273
+ // (content[0].text); never throws on an unexpected shape.
274
+ export function resultText(result: CallToolResult): string {
275
+ const first = result.content[0];
276
+ return first && first.type === 'text' && typeof first.text === 'string' ? first.text : '{}';
277
+ }
@@ -1,5 +1,22 @@
1
1
  import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
2
- import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
2
+ import { readEnvVar } from '@chrischall/mcp-utils';
3
+ import { CONFIRM_SEND_INSTRUCTION, requireDispatchConfirmation, type DispatchTokenFallback } from './dispatch-confirmation.js';
4
+
5
+ // The generic pieces moved to dispatch-confirmation.ts when Chat, Calendar,
6
+ // Drive and Classroom joined the rail; re-exported so Gmail callers keep one import.
7
+ export {
8
+ attachmentDetails,
9
+ attachmentNames,
10
+ BODY_PREVIEW_MAX,
11
+ bodyPreview,
12
+ attachmentPreview,
13
+ CONFIRM_FALLBACK_DESCRIPTION,
14
+ CONFIRM_SEND_INSTRUCTION as CONFIRM_INSTRUCTION,
15
+ confirmTokenParam,
16
+ resultText,
17
+ senderPreview,
18
+ } from './dispatch-confirmation.js';
19
+ export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './dispatch-confirmation.js';
3
20
 
4
21
  // ============================================================================
5
22
  // THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply /
@@ -15,6 +32,12 @@ import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
15
32
  // user's accepted confirmation dispatches. The confirmation is never a tool
16
33
  // argument, so a model cannot bypass the user by setting a boolean itself.
17
34
  //
35
+ // The one exception is OPT-IN: with GOG_SEND_CONFIRM_FALLBACK=token, a client
36
+ // that cannot be prompted gets a two-phase preview + confirmToken instead of a
37
+ // refusal (`DispatchTokenFallback`, send-confirm-token.ts). There the approval
38
+ // IS a tool argument; see that file for exactly what the token does and does
39
+ // not guarantee.
40
+ //
18
41
  // A CLIENT THAT CANNOT SHOW THAT PROMPT gets a sentence rather than a prompt
19
42
  // it will refuse to deliver (`unsupportedNote`, mcp-utils `requireConfirmation`).
20
43
  // Measured on the mcp-host fleet 2026-09-20: claude.ai declares no MCP
@@ -97,41 +120,26 @@ const UNSUPPORTED_NOTE: Partial<Record<GmailDispatchOp, string>> = {
97
120
  'gmail.drafts-send': 'The draft is still saved: ask the user to review it and send it from Gmail.',
98
121
  };
99
122
 
100
- // Bound on the body text shown in a confirmation prompt. Enough to read what is
101
- // actually being sent (the point of SEC-5), small enough to keep the prompt a
102
- // prompt rather than a copy of the message.
103
- export const BODY_PREVIEW_MAX = 2048;
104
-
105
- /** The first BODY_PREVIEW_MAX characters of a body, marked when cut. */
106
- export function bodyPreview(text: string | undefined): string | undefined {
107
- if (!text) return undefined;
108
- if (text.length <= BODY_PREVIEW_MAX) return text;
109
- return `${text.slice(0, BODY_PREVIEW_MAX)}… [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
110
- }
111
-
112
123
  /**
113
- * Every attachment a dispatch will carry: a server path in full (so the user
114
- * sees WHICH file on the gog host is leaving), an inline one by its filename.
124
+ * Apply the shared stateless confirmation flow with Gmail-specific copy.
125
+ *
126
+ * Elicitation stays the primary path and is untouched. Only when the caller
127
+ * declares it cannot be prompted AND a `fallback` is supplied AND
128
+ * GOG_SEND_CONFIRM_FALLBACK=token does the two-phase token flow run instead of
129
+ * the refusal; with the env unset, the refusal names that switch.
115
130
  */
116
- export function attachmentNames(
117
- paths: readonly string[] | undefined,
118
- inline: ReadonlyArray<{ filename: string }> | undefined,
119
- ): string[] {
120
- return [...(paths ?? []), ...(inline ?? []).map((a) => a.filename)];
121
- }
122
-
123
- /** Apply the shared stateless confirmation flow with Gmail-specific copy. */
124
- export function requireGmailDispatchConfirmation(
131
+ export async function requireGmailDispatchConfirmation(
125
132
  ctx: ServerContext,
126
133
  op: GmailDispatchOp,
127
134
  details: Record<string, unknown>,
128
- ): InputRequiredResult | CallToolResult | undefined {
135
+ fallback?: DispatchTokenFallback,
136
+ ): Promise<InputRequiredResult | CallToolResult | undefined> {
129
137
  const twin = STAGING_TWIN[op];
130
138
  const note = twin
131
139
  ? `Stage it with ${twin} instead; the user can review the draft and send it from Gmail `
132
140
  + '(gog_gmail_drafts_send also asks for confirmation).'
133
141
  : UNSUPPORTED_NOTE[op];
134
- return requireConfirmation(ctx, {
142
+ return requireDispatchConfirmation(ctx, {
135
143
  action: op,
136
144
  message: op === 'gmail.filter-forward'
137
145
  ? 'Review and confirm this mail-forwarding filter:'
@@ -140,7 +148,8 @@ export function requireGmailDispatchConfirmation(
140
148
  confirmationLabel: op === 'gmail.filter-forward'
141
149
  ? 'Confirm that matching mail should be forwarded automatically from now on.'
142
150
  : 'Confirm that this email should be sent now.',
143
- ...(note ? { unsupportedNote: note } : {}),
151
+ unsupportedNote: note,
152
+ ...(fallback ? { fallback: { instruction: CONFIRM_SEND_INSTRUCTION, ...fallback } } : {}),
144
153
  });
145
154
  }
146
155
 
@@ -210,12 +219,3 @@ export function logGmailDispatch(tool: string, recipients: string[], account?: s
210
219
  };
211
220
  process.stderr.write(`${JSON.stringify(event)}\n`);
212
221
  }
213
-
214
- // The single place a CallToolResult's text is pulled back out, for the tools
215
- // here that need to read gog's own JSON before deciding what to preview or
216
- // log. Mirrors the shape every runOrDiagnose result actually returns
217
- // (content[0].text); never throws on an unexpected shape.
218
- export function resultText(result: CallToolResult): string {
219
- const first = result.content[0];
220
- return first && first.type === 'text' && typeof first.text === 'string' ? first.text : '{}';
221
- }
package/src/lib.ts CHANGED
@@ -26,13 +26,22 @@ export type { ReplyFlags } from './tools/gmail.js';
26
26
  // gmail sub-package's send-side forward/autoreply tools reuse these directly
27
27
  // rather than re-declaring the gate; the draft-side twins never import them.
28
28
  export {
29
+ attachmentDetails,
29
30
  attachmentNames,
31
+ attachmentPreview,
30
32
  bodyPreview,
33
+ CONFIRM_FALLBACK_DESCRIPTION,
34
+ confirmTokenParam,
31
35
  extractEmails,
32
36
  logGmailDispatch,
33
37
  requireGmailDispatchConfirmation,
34
38
  resultText,
39
+ senderPreview,
35
40
  } from './gmail-dispatch-guard.js';
41
+ // The service-neutral rail for every other tool that reaches another person.
42
+ export { requireDispatchConfirmation } from './dispatch-confirmation.js';
43
+ export { readCourse } from './tools/classroom.js';
44
+ export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './gmail-dispatch-guard.js';
36
45
  export { run, runBinary, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
37
46
  // Sub-package tools that read gog JSON through bare `run()` (rather than the
38
47
  // `runOrDiagnose` seam) must still apply this, or their timestamps skip the
@@ -0,0 +1,167 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { readEnvVar } from '@chrischall/mcp-utils';
3
+
4
+ // ============================================================================
5
+ // THE TOKEN FALLBACK for the Gmail dispatch rail (gmail-dispatch-guard.ts).
6
+ //
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.
13
+ //
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.
26
+ // ============================================================================
27
+
28
+ export type ConfirmTokenError = 'DRAFT_CHANGED' | 'TOKEN_EXPIRED' | 'TOKEN_REUSED' | 'TOKEN_INVALID';
29
+
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;
40
+ }
41
+
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. */
83
+ export function resetConfirmTokenState(): void {
84
+ secret = undefined;
85
+ spent.clear();
86
+ }
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,6 +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
10
 
10
11
  // Gmail methods gog_api_call refuses outright (audit SEC-2). `allowWrite` is a
11
12
  // boolean the MODEL sets, so it cannot stand in for the user's confirmation of
@@ -15,12 +16,32 @@ import type { GogArg } from '../runner.js';
15
16
  // the same reason. --gmail-no-send is pinned on as a runtime backstop too.
16
17
  const GMAIL_API_BLOCKED = /(?:\.send$|forwarding|filters\.create|filters\.update|delegates\.create)/i;
17
18
 
19
+ // The same reasoning for every other action the dispatch rail gates (#400):
20
+ // the dedicated tool asks the user, so the raw API method must not be a way
21
+ // around it. Anchored on a `.` or the start so a Discovery id with the API
22
+ // prefix (`chat.spaces.messages.create`) matches too.
23
+ 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' }],
26
+ classroom: [
27
+ { method: /(?:^|\.)courses\.announcements\.create$/i, does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
28
+ { method: /(?:^|\.)invitations\.create$/i, does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
29
+ ],
30
+ calendar: [
31
+ { method: /(?:^|\.)events\.(?:insert|import|quickadd)$/i, does: 'can put an event on guests\' calendars', tool: 'gog_calendar_create' },
32
+ { method: /(?:^|\.)events\.(?:update|patch)$/i, does: 'can change what guests see', tool: 'gog_calendar_update / gog_calendar_respond' },
33
+ ],
34
+ };
35
+
18
36
  export function refusedApiCall(api: string, method: string): string | undefined {
19
- if (api.trim().toLowerCase() === 'gmail' && GMAIL_API_BLOCKED.test(method.trim())) {
37
+ const name = api.trim().toLowerCase();
38
+ const m = method.trim();
39
+ if (name === 'gmail' && GMAIL_API_BLOCKED.test(m)) {
20
40
  return `gmail ${method} is not available through gog_api_call: it sends or forwards mail. `
21
41
  + 'Use gog_gmail_send / gog_gmail_drafts_send (which ask the user to confirm) or the dedicated gog_gmail_* tool.';
22
42
  }
23
- return undefined;
43
+ const hit = (Object.hasOwn(DISPATCH_API_BLOCKED, name) ? DISPATCH_API_BLOCKED[name] : [])!.find((b) => b.method.test(m));
44
+ return hit ? gatedElsewhere(`${name} ${m}`, 'gog_api_call', hit.does, hit.tool) : undefined;
24
45
  }
25
46
 
26
47
  // Generic Google Discovery API access (gog 0.31). gog_api_list / gog_api_describe
@@ -57,7 +78,7 @@ export function registerApiTools(server: McpServer): void {
57
78
  });
58
79
 
59
80
  server.registerTool('gog_api_call', {
60
- 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.',
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.',
61
82
  annotations: { destructiveHint: true },
62
83
  inputSchema: z.object({
63
84
  api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),