gogcli-mcp 4.2.5 → 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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +1340 -262
  4. package/dist/lib.js +1381 -260
  5. package/manifest.json +9 -2
  6. package/package.json +2 -2
  7. package/server.json +2 -2
  8. package/src/arg-guard.ts +68 -0
  9. package/src/argv.ts +27 -0
  10. package/src/attachment-root.ts +111 -0
  11. package/src/attachments.ts +1 -0
  12. package/src/blob-upload.ts +4 -2
  13. package/src/dispatch-confirmation.ts +277 -0
  14. package/src/file-roots.ts +66 -0
  15. package/src/gmail-dispatch-guard.ts +70 -30
  16. package/src/gmail-results.ts +21 -2
  17. package/src/lib.ts +15 -0
  18. package/src/run-path-guard.ts +118 -0
  19. package/src/runner.ts +86 -18
  20. package/src/send-confirm-token.ts +167 -0
  21. package/src/tools/api.ts +60 -7
  22. package/src/tools/appscript.ts +12 -8
  23. package/src/tools/auth.ts +13 -3
  24. package/src/tools/calendar.ts +178 -15
  25. package/src/tools/chat.ts +94 -18
  26. package/src/tools/classroom.ts +110 -28
  27. package/src/tools/contacts.ts +5 -3
  28. package/src/tools/docs.ts +8 -6
  29. package/src/tools/drive.ts +96 -20
  30. package/src/tools/gmail.ts +175 -24
  31. package/src/tools/sheets.ts +10 -8
  32. package/src/tools/slides.ts +14 -9
  33. package/src/tools/tasks.ts +7 -5
  34. package/src/tools/utils.ts +52 -6
  35. package/tests/arg-guard.test.ts +80 -0
  36. package/tests/attachment-root.test.ts +130 -0
  37. package/tests/attachments.test.ts +8 -0
  38. package/tests/blob-upload.test.ts +4 -3
  39. package/tests/file-roots.test.ts +101 -0
  40. package/tests/gmail-dispatch-guard.test.ts +270 -11
  41. package/tests/gmail-results.test.ts +35 -1
  42. package/tests/run-path-guard.test.ts +142 -0
  43. package/tests/runner.test.ts +136 -0
  44. package/tests/send-confirm-token.test.ts +200 -0
  45. package/tests/tools/api.test.ts +74 -8
  46. package/tests/tools/appscript.test.ts +34 -8
  47. package/tests/tools/auth.test.ts +44 -17
  48. package/tests/tools/calendar.test.ts +33 -28
  49. package/tests/tools/chat.test.ts +50 -21
  50. package/tests/tools/classroom.test.ts +43 -39
  51. package/tests/tools/contacts.test.ts +3 -2
  52. package/tests/tools/dispatch-gates.test.ts +396 -0
  53. package/tests/tools/docs.test.ts +44 -15
  54. package/tests/tools/drive.test.ts +110 -20
  55. package/tests/tools/gmail-confirm-token.test.ts +274 -0
  56. package/tests/tools/gmail.test.ts +227 -29
  57. package/tests/tools/run-tool-examples.test.ts +69 -0
  58. package/tests/tools/run-vets.test.ts +131 -0
  59. package/tests/tools/sheets.test.ts +16 -15
  60. package/tests/tools/slides.test.ts +47 -11
  61. package/tests/tools/tasks.test.ts +7 -6
  62. package/tests/tools/utils.test.ts +32 -31
  63. package/vitest.config.ts +5 -0
@@ -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
@@ -1,6 +1,48 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose } from './utils.js';
3
+ import { errorResult } from '@chrischall/mcp-utils';
4
+ import { accountParam, errorText, runOrDiagnose } from './utils.js';
5
+ import { assertSafeForwardedArgs } from '../arg-guard.js';
6
+ import { confineAtFile } from '../file-roots.js';
7
+ import { pos } from '../argv.js';
8
+ import type { GogArg } from '../runner.js';
9
+ import { gatedElsewhere } from '../dispatch-confirmation.js';
10
+
11
+ // Gmail methods gog_api_call refuses outright (audit SEC-2). `allowWrite` is a
12
+ // boolean the MODEL sets, so it cannot stand in for the user's confirmation of
13
+ // a send: `*.send` must go through gog_gmail_send / gog_gmail_drafts_send, which
14
+ // ask the user. Forwarding addresses, auto-forwarding, filters (which can
15
+ // forward) and delegates route FUTURE mail to someone else and are refused for
16
+ // the same reason. --gmail-no-send is pinned on as a runtime backstop too.
17
+ const GMAIL_API_BLOCKED = /(?:\.send$|forwarding|filters\.create|filters\.update|delegates\.create)/i;
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
+
36
+ export function refusedApiCall(api: string, method: string): string | undefined {
37
+ const name = api.trim().toLowerCase();
38
+ const m = method.trim();
39
+ if (name === 'gmail' && GMAIL_API_BLOCKED.test(m)) {
40
+ return `gmail ${method} is not available through gog_api_call: it sends or forwards mail. `
41
+ + 'Use gog_gmail_send / gog_gmail_drafts_send (which ask the user to confirm) or the dedicated gog_gmail_* tool.';
42
+ }
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;
45
+ }
4
46
 
5
47
  // Generic Google Discovery API access (gog 0.31). gog_api_list / gog_api_describe
6
48
  // are read-only Discovery lookups; gog_api_call is a Discovery-backed escape
@@ -15,7 +57,7 @@ export function registerApiTools(server: McpServer): void {
15
57
  account: accountParam,
16
58
  }),
17
59
  }, async ({ all, account }) => {
18
- const args = ['api', 'list'];
60
+ const args: GogArg[] = ['api', 'list'];
19
61
  if (all) args.push('--all');
20
62
  return runOrDiagnose(args, { account });
21
63
  });
@@ -30,13 +72,13 @@ export function registerApiTools(server: McpServer): void {
30
72
  account: accountParam,
31
73
  }),
32
74
  }, async ({ api, version, method, account }) => {
33
- const args = ['api', 'describe', api, version];
34
- if (method) args.push(method);
75
+ const args: GogArg[] = ['api', 'describe', pos(api), pos(version)];
76
+ if (method) args.push(pos(method));
35
77
  return runOrDiagnose(args, { account });
36
78
  });
37
79
 
38
80
  server.registerTool('gog_api_call', {
39
- 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.',
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.',
40
82
  annotations: { destructiveHint: true },
41
83
  inputSchema: z.object({
42
84
  api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
@@ -50,7 +92,18 @@ export function registerApiTools(server: McpServer): void {
50
92
  account: accountParam,
51
93
  }),
52
94
  }, async ({ api, version, method, params, body, scope, allowWrite, dryRun, account }) => {
53
- const args = ['api', 'call', api, version, method];
95
+ try {
96
+ assertSafeForwardedArgs([api, version, method]);
97
+ const refusal = refusedApiCall(api, method);
98
+ if (refusal) throw new Error(refusal);
99
+ // gog reads `@path` from the host for --body (and --params): confine it
100
+ // like every other server-side path (SEC-3/SEC-4).
101
+ if (body) confineAtFile(body, 'body');
102
+ if (params) confineAtFile(params, 'params');
103
+ } catch (err) {
104
+ return errorResult(errorText(err));
105
+ }
106
+ const args: GogArg[] = ['api', 'call', pos(api), pos(version), pos(method)];
54
107
  if (params) args.push(`--params=${params}`);
55
108
  if (body) args.push(`--body=${body}`);
56
109
  if (scope) args.push(`--scope=${scope}`);
@@ -60,6 +113,6 @@ export function registerApiTools(server: McpServer): void {
60
113
  if (dryRun) args.push('--dry-run');
61
114
  // Fleet convention: --force is appended LAST (after --dry-run when both are set).
62
115
  if (allowWrite) args.push('--force');
63
- return runOrDiagnose(args, { account });
116
+ return runOrDiagnose(args, { account, gmailNoSend: true });
64
117
  });
65
118
  }
@@ -7,6 +7,9 @@ import {
7
7
  paginationParams,
8
8
  pushPaginationFlags,
9
9
  } from './utils.js';
10
+ import { pos } from '../argv.js';
11
+ import { confinePath } from '../file-roots.js';
12
+ import type { GogArg } from '../runner.js';
10
13
 
11
14
  // Google Apps Script (gog >= 0.38.0 for pull/deployments/versions).
12
15
  //
@@ -40,7 +43,7 @@ export function registerAppScriptTools(server: McpServer): void {
40
43
  account: accountParam,
41
44
  }),
42
45
  }, async ({ scriptId, account }) => {
43
- return runOrDiagnose(['appscript', 'get', scriptId], { account });
46
+ return runOrDiagnose(['appscript', 'get', pos(scriptId)], { account });
44
47
  });
45
48
 
46
49
  server.registerTool('gog_appscript_content', {
@@ -54,7 +57,7 @@ export function registerAppScriptTools(server: McpServer): void {
54
57
  account: accountParam,
55
58
  }),
56
59
  }, async ({ scriptId, account }) => {
57
- return runOrDiagnose(['appscript', 'content', scriptId], { account });
60
+ return runOrDiagnose(['appscript', 'content', pos(scriptId)], { account });
58
61
  });
59
62
 
60
63
  server.registerTool('gog_appscript_pull', {
@@ -68,12 +71,13 @@ export function registerAppScriptTools(server: McpServer): void {
68
71
  annotations: { destructiveHint: true },
69
72
  inputSchema: z.object({
70
73
  scriptId: scriptIdParam,
71
- dir: z.string().describe('Destination directory, resolved on the machine where gog runs'),
74
+ dir: z.string().describe('Destination directory, resolved on the machine where gog runs; must be inside the server\'s GOG_FILE_ROOTS directories'),
72
75
  overwrite: z.boolean().optional().describe('Overwrite files that already exist in dir'),
73
76
  account: accountParam,
74
77
  }),
75
78
  }, async ({ scriptId, dir, overwrite, account }) => {
76
- const args = ['appscript', 'pull', scriptId, dir];
79
+ confinePath(dir, 'dir');
80
+ const args: GogArg[] = ['appscript', 'pull', pos(scriptId), pos(dir)];
77
81
  if (overwrite) args.push('--overwrite');
78
82
  return runOrDiagnose(args, { account });
79
83
  });
@@ -90,7 +94,7 @@ export function registerAppScriptTools(server: McpServer): void {
90
94
  account: accountParam,
91
95
  }),
92
96
  }, async ({ title, parentId, account }) => {
93
- const args = ['appscript', 'create', `--title=${title}`];
97
+ const args: GogArg[] = ['appscript', 'create', `--title=${title}`];
94
98
  if (parentId) args.push(`--parent-id=${parentId}`);
95
99
  return runOrDiagnose(args, { account });
96
100
  });
@@ -107,7 +111,7 @@ export function registerAppScriptTools(server: McpServer): void {
107
111
  account: accountParam,
108
112
  }),
109
113
  }, async ({ scriptId, max, pageToken, page, all, account }) => {
110
- const args = ['appscript', 'deployments', scriptId];
114
+ const args: GogArg[] = ['appscript', 'deployments', pos(scriptId)];
111
115
  pushPaginationFlags(args, { max, pageToken, page, all });
112
116
  return runOrDiagnose(args, { account });
113
117
  });
@@ -123,7 +127,7 @@ export function registerAppScriptTools(server: McpServer): void {
123
127
  account: accountParam,
124
128
  }),
125
129
  }, async ({ scriptId, max, pageToken, page, all, account }) => {
126
- const args = ['appscript', 'versions', scriptId];
130
+ const args: GogArg[] = ['appscript', 'versions', pos(scriptId)];
127
131
  pushPaginationFlags(args, { max, pageToken, page, all });
128
132
  return runOrDiagnose(args, { account });
129
133
  });
@@ -161,7 +165,7 @@ export function registerAppScriptTools(server: McpServer): void {
161
165
  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}`);
162
166
  }
163
167
  }
164
- const args = ['appscript', 'run', scriptId, functionName];
168
+ const args: GogArg[] = ['appscript', 'run', pos(scriptId), pos(functionName)];
165
169
  if (params !== undefined) args.push(`--params=${params}`);
166
170
  if (devMode) args.push('--dev-mode');
167
171
  return runOrDiagnose(args, { account });
package/src/tools/auth.ts CHANGED
@@ -3,6 +3,11 @@ import { z } from 'zod';
3
3
  import { run } from '../runner.js';
4
4
  import { errorResult, rawTextResult } from '@chrischall/mcp-utils';
5
5
  import { errorText, formatAuthHealth, registerRunTool } from './utils.js';
6
+ import { pos } from '../argv.js';
7
+ import type { GogArg } from '../runner.js';
8
+
9
+ /** The `gog auth` subcommands gog_auth_run may run — account management only. */
10
+ export const AUTH_RUN_SUBCOMMANDS: readonly string[] = ['list', 'status', 'services', 'remove', 'alias'];
6
11
 
7
12
  // Register the auth tools with a specific least-privilege default `services`.
8
13
  // Kept internal so the exported `registerAuthTools` stays a bare
@@ -109,7 +114,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
109
114
  }),
110
115
  }, async ({ email, services = defaultServices, extraScopes }) => {
111
116
  try {
112
- const args = ['auth', 'add', email, '--services', services];
117
+ const args: GogArg[] = ['auth', 'add', pos(email), '--services', services];
113
118
  // --force-consent rides along with extraScopes and only with them. Google
114
119
  // re-prompts for a NEW scope only when consent is forced; without it the
115
120
  // account can come back still missing the scope, with a success message —
@@ -147,7 +152,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
147
152
  // (the whole point when recovering from a dead one). redactMode 'tokens'
148
153
  // keeps the consent URL's scope names intact (the shared redactor mangles
149
154
  // them) while still stripping any real token — a step-1 URL carries none.
150
- const args = ['auth', 'add', email, '--remote', '--step', '1', '--services', services, '--force-consent'];
155
+ const args: GogArg[] = ['auth', 'add', pos(email), '--remote', '--step', '1', '--services', services, '--force-consent'];
151
156
  if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
152
157
  return rawTextResult(await run(args, { redactMode: 'tokens' }));
153
158
  } catch (err) {
@@ -178,7 +183,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
178
183
  }),
179
184
  }, async ({ email, redirectUrl, services = defaultServices, extraScopes }) => {
180
185
  try {
181
- const args = ['auth', 'add', email, '--remote', '--step', '2', '--auth-url', redirectUrl,
186
+ const args: GogArg[] = ['auth', 'add', pos(email), '--remote', '--step', '2', '--auth-url', redirectUrl,
182
187
  '--services', services, '--force-consent'];
183
188
  if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
184
189
  return rawTextResult(await run(args));
@@ -187,10 +192,15 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
187
192
  }
188
193
  });
189
194
 
195
+ // Account management only (audit SEC-3). `auth tokens export` writes a
196
+ // long-lived refresh token to a file, and `credentials` / `keyring` /
197
+ // `tokens import` handle the OAuth client secret and the token store — none of
198
+ // that belongs behind a model-callable escape hatch. `add` has its own tools.
190
199
  registerRunTool(server, {
191
200
  service: 'auth',
192
201
  examples: '"remove", "alias", "list"',
193
202
  omitAccount: true,
203
+ allowedSubcommands: AUTH_RUN_SUBCOMMANDS,
194
204
  note: 'For browser-based authorization, use gog_auth_add instead.',
195
205
  });
196
206
  }