gogcli-mcp 4.2.5 → 4.3.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 (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +714 -216
  4. package/dist/lib.js +750 -216
  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/file-roots.ts +66 -0
  14. package/src/gmail-dispatch-guard.ts +56 -16
  15. package/src/gmail-results.ts +21 -2
  16. package/src/lib.ts +6 -0
  17. package/src/run-path-guard.ts +118 -0
  18. package/src/runner.ts +86 -18
  19. package/src/tools/api.ts +39 -7
  20. package/src/tools/appscript.ts +12 -8
  21. package/src/tools/auth.ts +13 -3
  22. package/src/tools/calendar.ts +10 -8
  23. package/src/tools/chat.ts +16 -13
  24. package/src/tools/classroom.ts +27 -25
  25. package/src/tools/contacts.ts +5 -3
  26. package/src/tools/docs.ts +8 -6
  27. package/src/tools/drive.ts +42 -18
  28. package/src/tools/gmail.ts +71 -11
  29. package/src/tools/sheets.ts +10 -8
  30. package/src/tools/slides.ts +14 -9
  31. package/src/tools/tasks.ts +7 -5
  32. package/src/tools/utils.ts +52 -6
  33. package/tests/arg-guard.test.ts +80 -0
  34. package/tests/attachment-root.test.ts +130 -0
  35. package/tests/attachments.test.ts +8 -0
  36. package/tests/blob-upload.test.ts +4 -3
  37. package/tests/file-roots.test.ts +101 -0
  38. package/tests/gmail-dispatch-guard.test.ts +46 -0
  39. package/tests/gmail-results.test.ts +35 -1
  40. package/tests/run-path-guard.test.ts +142 -0
  41. package/tests/runner.test.ts +136 -0
  42. package/tests/tools/api.test.ts +74 -8
  43. package/tests/tools/appscript.test.ts +34 -8
  44. package/tests/tools/auth.test.ts +44 -17
  45. package/tests/tools/calendar.test.ts +29 -27
  46. package/tests/tools/chat.test.ts +46 -20
  47. package/tests/tools/classroom.test.ts +39 -38
  48. package/tests/tools/contacts.test.ts +3 -2
  49. package/tests/tools/docs.test.ts +44 -15
  50. package/tests/tools/drive.test.ts +106 -19
  51. package/tests/tools/gmail.test.ts +227 -29
  52. package/tests/tools/run-tool-examples.test.ts +69 -0
  53. package/tests/tools/sheets.test.ts +16 -15
  54. package/tests/tools/slides.test.ts +47 -11
  55. package/tests/tools/tasks.test.ts +7 -6
  56. package/tests/tools/utils.test.ts +32 -31
  57. package/vitest.config.ts +5 -0
@@ -0,0 +1,118 @@
1
+ // GOG_FILE_ROOTS for the escape hatches (audit SEC-3/SEC-4).
2
+ //
3
+ // The structured tools confine every server-side path they accept, but a
4
+ // `gog_<service>_run` forwards a model-supplied string[] verbatim, and gog reads
5
+ // and writes local files through it just as readily: `drive upload
6
+ // <gog's credentials.json>`, `gmail drafts create --attach=~/.ssh/id_rsa`,
7
+ // `docs export --out=~/.zshrc`. Left open, that is the read-and-exfiltrate and
8
+ // write-anywhere primitive the roots exist to close, and GOG_FILE_ROOTS would
9
+ // only look like a boundary. So, before an escape hatch runs:
10
+ //
11
+ // - subcommands whose local path is a POSITIONAL are refused outright. Which
12
+ // positional is the path depends on the command, and kong lets flags sit
13
+ // anywhere, so it cannot be picked out reliably; each has a dedicated tool
14
+ // that confines it.
15
+ // - every path-bearing FLAG value (`--out`, `--attach`, `--file`, `--*-file`,
16
+ // `--out-dir`, `--dir`, an `@file` JSON input, ...) must resolve inside the
17
+ // roots, whether it is attached (`--out=x`) or the next token (`--out x`).
18
+ // - the short spellings `-o` / `-f` are refused, since a cluster (`-yf x`,
19
+ // `-o/etc/x`) hides the value; the long form is confined instead.
20
+ // - flags that make gog RUN a local program (`--on-change`, `--on-new`,
21
+ // `--mmdc`) are refused: a model-chosen shell command is a strictly worse
22
+ // primitive than a path.
23
+ //
24
+ // Kept out of runner.ts for the same reason as arg-guard.ts: tool tests
25
+ // automock the runner, and this has to run for real in the tool handlers.
26
+
27
+ import { confinePath } from './file-roots.js';
28
+
29
+ /** Escape-hatch subcommands whose local path is positional, and the tool to use instead. */
30
+ const POSITIONAL_PATH_SUBCOMMANDS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
31
+ appscript: { pull: 'gog_appscript_pull' },
32
+ drive: { upload: 'gog_drive_upload', sync: 'gog_drive_sync_push' },
33
+ gmail: { import: 'gog_gmail_import' },
34
+ slides: {
35
+ 'add-slide': 'gog_slides_add_slide',
36
+ 'insert-image': 'gog_slides_insert_image',
37
+ 'replace-slide': 'gog_slides_replace_slide',
38
+ },
39
+ };
40
+
41
+ /** Long flags (lower-case, no dashes) whose value is a local path (from `gog schema`, v0.41.0). */
42
+ const PATH_FLAGS = new Set(['out', 'output', 'out-dir', 'output-dir', 'dir', 'worker-dir', 'attach', 'file', 'key', 'cert', 'replacements']);
43
+
44
+ /** Per service: flags that look like paths but carry Drive file IDs. */
45
+ const ID_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
46
+ drive: new Set(['file', 'filter-file']),
47
+ };
48
+
49
+ /** Flags whose value gog executes as a local command or program. */
50
+ const EXEC_FLAGS = new Set(['on-change', 'on-new', 'mmdc']);
51
+
52
+ // Single-dash clusters containing -o (--out) or -f (--file).
53
+ const SHORT_PATH_CLUSTER = /^-(?!-)[A-Za-z]*[of]/;
54
+
55
+ const LONG_FLAG = /^--([A-Za-z0-9][A-Za-z0-9-]*)(?:=([\s\S]*))?$/;
56
+
57
+ function isPathFlag(service: string, name: string): boolean {
58
+ if (ID_FLAGS[service]?.has(name)) return false;
59
+ return PATH_FLAGS.has(name) || name.endsWith('-file');
60
+ }
61
+
62
+ function confineFlagValue(flag: string, value: string): void {
63
+ // `-` is stdin/stdout, which never names a file on the host.
64
+ if (value === '-') return;
65
+ confinePath(value, flag);
66
+ // kong splits a repeatable ([]string) flag on commas, so `--attach=a,b` is
67
+ // two paths: each must be inside the roots too.
68
+ for (const part of value.split(',')) {
69
+ if (part !== '' && part !== '-') confinePath(part, flag);
70
+ }
71
+ }
72
+
73
+ /** The dedicated tool to use when `gog <service> <subcommand>` is refused for a positional path, else undefined. */
74
+ export function positionalPathTool(service: string, subcommand: string): string | undefined {
75
+ return POSITIONAL_PATH_SUBCOMMANDS[service]?.[subcommand];
76
+ }
77
+
78
+ /**
79
+ * Throw unless every local path in an escape-hatch call (`gog <service>
80
+ * <subcommand> ...args`) lies inside GOG_FILE_ROOTS (or the private attachment
81
+ * download root), and nothing in it would run a local program.
82
+ */
83
+ export function assertRunPathsConfined(service: string, subcommand: string, args: readonly string[]): void {
84
+ const dedicated = positionalPathTool(service, subcommand);
85
+ if (dedicated) {
86
+ throw new Error(
87
+ `gog ${service} ${subcommand} reads or writes a local path given as a positional argument, so it is not available through gog_${service}_run. Use ${dedicated}, which confines the path to GOG_FILE_ROOTS.`,
88
+ );
89
+ }
90
+ for (let i = 0; i < args.length; i++) {
91
+ const arg = args[i];
92
+ if (SHORT_PATH_CLUSTER.test(arg)) {
93
+ throw new Error(
94
+ `The flag ${arg} is not allowed here: spell a path flag out as --out or --file (e.g. --out=<path>) so its path can be checked against GOG_FILE_ROOTS.`,
95
+ );
96
+ }
97
+ const match = LONG_FLAG.exec(arg);
98
+ if (!match) continue;
99
+ const name = match[1].toLowerCase();
100
+ if (EXEC_FLAGS.has(name)) {
101
+ throw new Error(`The flag --${name} is not allowed here: it runs a local program chosen by the caller.`);
102
+ }
103
+ const isJson = name.endsWith('-json');
104
+ if (!isJson && !isPathFlag(service, name)) continue;
105
+ let value = match[2];
106
+ if (value === undefined) {
107
+ value = args[i + 1];
108
+ if (value === undefined) continue;
109
+ i++;
110
+ }
111
+ if (isJson) {
112
+ // Inline JSON passes; `@path` reads a file, `@-` reads stdin.
113
+ if (value.startsWith('@')) confineFlagValue(`--${name}`, value.slice(1));
114
+ continue;
115
+ }
116
+ confineFlagValue(`--${name}`, value);
117
+ }
118
+ }
package/src/runner.ts CHANGED
@@ -2,6 +2,10 @@ import type { ChildProcess } from 'node:child_process';
2
2
  import { delimiter, join } from 'node:path';
3
3
  import { currentCallSignal, killOnCancel, parseBoolEnv, readEnvVar, redactSecrets as redactSharedSecrets } from '@chrischall/mcp-utils';
4
4
  import { naiveSourceTimeZone } from './timestamps.js';
5
+ import { forbiddenArgReason } from './arg-guard.js';
6
+ import { isGogPositional, type GogPositional } from './argv.js';
7
+
8
+ export type { GogPositional } from './argv.js';
5
9
 
6
10
  export type Spawner = (
7
11
  command: string,
@@ -55,10 +59,10 @@ export interface GogFileArg {
55
59
  positional?: boolean;
56
60
  }
57
61
 
58
- export type GogArg = string | GogFileArg;
62
+ export type GogArg = string | GogFileArg | GogPositional;
59
63
 
60
64
  export function isGogFileArg(arg: GogArg): arg is GogFileArg {
61
- return typeof arg !== 'string';
65
+ return typeof arg !== 'string' && arg.kind === 'file';
62
66
  }
63
67
 
64
68
  export interface RunOptions {
@@ -97,6 +101,15 @@ export interface RunOptions {
97
101
  // (see OPAQUE_FIELD_VALUE) — so a field carrying real prose, which is where a
98
102
  // real leaked token would live, still gets redacted normally.
99
103
  opaqueFields?: readonly string[];
104
+ // Inject gog's global --gmail-no-send, which blocks every Gmail send
105
+ // (send, reply, forward, drafts send, users.messages.send via api call) at
106
+ // runtime. Set by the escape hatches so none of them can dispatch mail
107
+ // around the confirmation rail.
108
+ gmailNoSend?: boolean;
109
+ // Ceiling on the bytes gog may write to stdout. Past it the child is killed
110
+ // and the call rejects, so one oversized download (a multi-GB Drive file read
111
+ // through runBinary) cannot exhaust this process's memory.
112
+ maxOutputBytes?: number;
100
113
  }
101
114
 
102
115
  const TIMEOUT_MS = 30_000;
@@ -300,8 +313,8 @@ function formatTimeout(ms: number): string {
300
313
  // plain argv, and remove the temp dir afterwards — on success, on a non-zero
301
314
  // exit, and on timeout alike. A leaked temp file holds user email content.
302
315
  async function spawnWithTempFiles(
303
- args: GogArg[],
304
- opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
316
+ args: Array<string | GogFileArg>,
317
+ opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean; maxOutputBytes?: number },
305
318
  ): Promise<string> {
306
319
  const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
307
320
  const { tmpdir } = await import('node:os');
@@ -350,8 +363,8 @@ async function spawnWithTempFiles(
350
363
  // to happen synchronously within the `run()` call, which the fake-timer tests
351
364
  // in tests/runner.test.ts depend on.
352
365
  function spawnExecutor(
353
- args: GogArg[],
354
- opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
366
+ args: Array<string | GogFileArg>,
367
+ opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean; maxOutputBytes?: number },
355
368
  ): Promise<string> {
356
369
  if (args.some(isGogFileArg)) {
357
370
  return spawnWithTempFiles(args, opts);
@@ -365,9 +378,9 @@ function spawnExecutor(
365
378
  // injected `spawner` bypasses the real child_process spawn.
366
379
  async function spawnGog(
367
380
  fullArgs: string[],
368
- opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
381
+ opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean; maxOutputBytes?: number },
369
382
  ): Promise<string> {
370
- const { timeout, interactive = false, spawner, binary = false } = opts;
383
+ const { timeout, interactive = false, spawner, binary = false, maxOutputBytes } = opts;
371
384
  const spawn = spawner ?? (await import('node:child_process')).spawn as unknown as Spawner;
372
385
  const effectiveTimeout = timeout ?? TIMEOUT_MS;
373
386
 
@@ -404,7 +417,19 @@ async function spawnGog(
404
417
  reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
405
418
  }, effectiveTimeout);
406
419
 
407
- child.stdout!.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk); });
420
+ let stdoutBytes = 0;
421
+ child.stdout!.on('data', (chunk: Buffer) => {
422
+ if (settled) return;
423
+ stdoutBytes += chunk.length;
424
+ if (maxOutputBytes !== undefined && stdoutBytes > maxOutputBytes) {
425
+ settled = true;
426
+ stopWatching();
427
+ child.kill();
428
+ reject(new Error(`gog output exceeded the limit: more than ${maxOutputBytes} bytes`));
429
+ return;
430
+ }
431
+ stdoutChunks.push(chunk);
432
+ });
408
433
  child.stderr!.on('data', (chunk: Buffer) => { stderrChunks.push(chunk); });
409
434
 
410
435
  child.on('close', (code: number | null) => {
@@ -458,12 +483,51 @@ async function spawnGog(
458
483
  // Assemble the full gog argv: the always-injected flags (--json/--color=never,
459
484
  // --no-input unless interactive, --readonly when opted in), --account, then the
460
485
  // caller's args. Shared by run() and runBinary() so both get identical flags.
486
+ // Split the caller's argv into flag position and positional position.
487
+ // pos()-marked values, positional file args, and anything after an explicit
488
+ // `--` are positionals; they go after ONE `--` at the very end, in their
489
+ // original order, so a value that starts with '-' is data, never a flag
490
+ // (audit BUG-1). Commands and flags keep their relative order. With nothing
491
+ // positional the argv is returned unchanged — no `--` at all.
492
+ function placePositionals(args: GogArg[]): { flags: Array<string | GogFileArg>; positionals: Array<string | GogFileArg> } {
493
+ const flags: Array<string | GogFileArg> = [];
494
+ const positionals: Array<string | GogFileArg> = [];
495
+ let afterSeparator = false;
496
+ for (const arg of args) {
497
+ if (arg === '--') {
498
+ afterSeparator = true;
499
+ } else if (isGogPositional(arg)) {
500
+ positionals.push(arg.value);
501
+ } else if (afterSeparator || (isGogFileArg(arg) && arg.positional)) {
502
+ positionals.push(arg);
503
+ } else {
504
+ flags.push(arg);
505
+ }
506
+ }
507
+ return { flags, positionals };
508
+ }
509
+
510
+ // Refuse a safety-control override sitting in FLAG position. The tool layer
511
+ // already vets model-supplied escape-hatch args (arg-guard.ts); this is the
512
+ // backstop for every other path, so no tool can hand gog a `--readonly=false`
513
+ // that would override the `--readonly` injected below (gog takes the LAST
514
+ // value of a repeated flag). Positionals are exempt: after `--` they are data.
515
+ function assertNoSafetyOverrides(flags: Array<string | GogFileArg>): void {
516
+ for (const arg of flags) {
517
+ if (typeof arg !== 'string') continue;
518
+ const reason = forbiddenArgReason(arg);
519
+ if (reason) throw new Error(reason);
520
+ }
521
+ }
522
+
461
523
  function assembleArgs(
462
524
  args: GogArg[],
463
- opts: { account?: string; interactive: boolean; readonly: boolean },
464
- ): GogArg[] {
525
+ opts: { account?: string; interactive: boolean; readonly: boolean; gmailNoSend: boolean },
526
+ ): Array<string | GogFileArg> {
527
+ const { flags, positionals } = placePositionals(args);
528
+ assertNoSafetyOverrides(flags);
465
529
  const effectiveAccount = opts.account ?? readEnvVar('GOG_ACCOUNT');
466
- const fullArgs: GogArg[] = ['--json', '--color=never'];
530
+ const fullArgs: Array<string | GogFileArg> = ['--json', '--color=never'];
467
531
  if (!opts.interactive) {
468
532
  fullArgs.push('--no-input');
469
533
  }
@@ -473,15 +537,19 @@ function assembleArgs(
473
537
  if (opts.readonly || readonlyEnvEnabled()) {
474
538
  fullArgs.push('--readonly');
475
539
  }
540
+ if (opts.gmailNoSend) {
541
+ fullArgs.push('--gmail-no-send');
542
+ }
476
543
  if (effectiveAccount) {
477
544
  fullArgs.push('--account', effectiveAccount);
478
545
  }
479
- fullArgs.push(...args);
546
+ fullArgs.push(...flags);
547
+ if (positionals.length > 0) fullArgs.push('--', ...positionals);
480
548
  return fullArgs;
481
549
  }
482
550
 
483
551
  export async function run(args: GogArg[], options: RunOptions = {}): Promise<string> {
484
- const { account, spawner, interactive = false, timeout, readonly = false, redactMode = 'full', opaqueFields } = options;
552
+ const { account, spawner, interactive = false, timeout, readonly = false, redactMode = 'full', opaqueFields, gmailNoSend = false } = options;
485
553
  const base = redactMode === 'tokens' ? redactGoogleTokens : redactSecrets;
486
554
  // Only OUTPUT carries opaque payloads. An error message is prose by
487
555
  // definition, so it always takes the plain redactor — exempting a field there
@@ -490,7 +558,7 @@ export async function run(args: GogArg[], options: RunOptions = {}): Promise<str
490
558
  ? (text: string): string => redactPreservingOpaqueFields(text, opaqueFields, base)
491
559
  : base;
492
560
 
493
- const fullArgs = assembleArgs(args, { account, interactive, readonly });
561
+ const fullArgs = assembleArgs(args, { account, interactive, readonly, gmailNoSend });
494
562
 
495
563
  // Redaction wraps the spawn: a successful `gog auth tokens` (or any command
496
564
  // echoing a credential) would otherwise return raw Google tokens (ya29.…/1//…)
@@ -511,7 +579,7 @@ export async function run(args: GogArg[], options: RunOptions = {}): Promise<str
511
579
  // would corrupt. No redaction: the base64 of a user's own binary file is opaque
512
580
  // and has no token shapes to leak.
513
581
  export async function runBinary(args: GogArg[], options: RunOptions = {}): Promise<string> {
514
- const { account, spawner, timeout, readonly = false } = options;
515
- const fullArgs = assembleArgs(args, { account, interactive: false, readonly });
516
- return spawnExecutor(fullArgs, { timeout, interactive: false, spawner, binary: true });
582
+ const { account, spawner, timeout, readonly = false, gmailNoSend = false, maxOutputBytes } = options;
583
+ const fullArgs = assembleArgs(args, { account, interactive: false, readonly, gmailNoSend });
584
+ return spawnExecutor(fullArgs, { timeout, interactive: false, spawner, binary: true, maxOutputBytes });
517
585
  }
package/src/tools/api.ts CHANGED
@@ -1,6 +1,27 @@
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
+
10
+ // Gmail methods gog_api_call refuses outright (audit SEC-2). `allowWrite` is a
11
+ // boolean the MODEL sets, so it cannot stand in for the user's confirmation of
12
+ // a send: `*.send` must go through gog_gmail_send / gog_gmail_drafts_send, which
13
+ // ask the user. Forwarding addresses, auto-forwarding, filters (which can
14
+ // forward) and delegates route FUTURE mail to someone else and are refused for
15
+ // the same reason. --gmail-no-send is pinned on as a runtime backstop too.
16
+ const GMAIL_API_BLOCKED = /(?:\.send$|forwarding|filters\.create|filters\.update|delegates\.create)/i;
17
+
18
+ export function refusedApiCall(api: string, method: string): string | undefined {
19
+ if (api.trim().toLowerCase() === 'gmail' && GMAIL_API_BLOCKED.test(method.trim())) {
20
+ return `gmail ${method} is not available through gog_api_call: it sends or forwards mail. `
21
+ + 'Use gog_gmail_send / gog_gmail_drafts_send (which ask the user to confirm) or the dedicated gog_gmail_* tool.';
22
+ }
23
+ return undefined;
24
+ }
4
25
 
5
26
  // Generic Google Discovery API access (gog 0.31). gog_api_list / gog_api_describe
6
27
  // are read-only Discovery lookups; gog_api_call is a Discovery-backed escape
@@ -15,7 +36,7 @@ export function registerApiTools(server: McpServer): void {
15
36
  account: accountParam,
16
37
  }),
17
38
  }, async ({ all, account }) => {
18
- const args = ['api', 'list'];
39
+ const args: GogArg[] = ['api', 'list'];
19
40
  if (all) args.push('--all');
20
41
  return runOrDiagnose(args, { account });
21
42
  });
@@ -30,13 +51,13 @@ export function registerApiTools(server: McpServer): void {
30
51
  account: accountParam,
31
52
  }),
32
53
  }, async ({ api, version, method, account }) => {
33
- const args = ['api', 'describe', api, version];
34
- if (method) args.push(method);
54
+ const args: GogArg[] = ['api', 'describe', pos(api), pos(version)];
55
+ if (method) args.push(pos(method));
35
56
  return runOrDiagnose(args, { account });
36
57
  });
37
58
 
38
59
  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.',
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.',
40
61
  annotations: { destructiveHint: true },
41
62
  inputSchema: z.object({
42
63
  api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
@@ -50,7 +71,18 @@ export function registerApiTools(server: McpServer): void {
50
71
  account: accountParam,
51
72
  }),
52
73
  }, async ({ api, version, method, params, body, scope, allowWrite, dryRun, account }) => {
53
- const args = ['api', 'call', api, version, method];
74
+ try {
75
+ assertSafeForwardedArgs([api, version, method]);
76
+ const refusal = refusedApiCall(api, method);
77
+ if (refusal) throw new Error(refusal);
78
+ // gog reads `@path` from the host for --body (and --params): confine it
79
+ // like every other server-side path (SEC-3/SEC-4).
80
+ if (body) confineAtFile(body, 'body');
81
+ if (params) confineAtFile(params, 'params');
82
+ } catch (err) {
83
+ return errorResult(errorText(err));
84
+ }
85
+ const args: GogArg[] = ['api', 'call', pos(api), pos(version), pos(method)];
54
86
  if (params) args.push(`--params=${params}`);
55
87
  if (body) args.push(`--body=${body}`);
56
88
  if (scope) args.push(`--scope=${scope}`);
@@ -60,6 +92,6 @@ export function registerApiTools(server: McpServer): void {
60
92
  if (dryRun) args.push('--dry-run');
61
93
  // Fleet convention: --force is appended LAST (after --dry-run when both are set).
62
94
  if (allowWrite) args.push('--force');
63
- return runOrDiagnose(args, { account });
95
+ return runOrDiagnose(args, { account, gmailNoSend: true });
64
96
  });
65
97
  }
@@ -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
  }
@@ -3,6 +3,8 @@ import { z } from 'zod';
3
3
  import { viewParam, resolveView } from '@chrischall/mcp-utils';
4
4
  import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
5
5
  import { annotateTruncatedList } from '../pagination.js';
6
+ import { pos } from '../argv.js';
7
+ import type { GogArg } from '../runner.js';
6
8
 
7
9
  // Reminder params, shared by create and update (gog >= 0.38.0 for
8
10
  // --no-reminders). An event's reminders are one of THREE states, and the two
@@ -33,7 +35,7 @@ const reminderParams = {
33
35
  // The one place the three states become argv. Kept together so create and
34
36
  // update cannot drift apart on the empty-array case.
35
37
  function pushReminderFlags(
36
- args: string[],
38
+ args: GogArg[],
37
39
  p: { reminders?: string[]; noReminders?: boolean },
38
40
  ): void {
39
41
  if (p.noReminders) {
@@ -107,8 +109,8 @@ export function registerCalendarTools(server: McpServer): void {
107
109
  account: accountParam,
108
110
  }),
109
111
  }, async ({ calendarId, from, to, days, today, query, max, pageToken, page, all, eventTypes, timezone, view, account }) => {
110
- const args = ['calendar', 'events'];
111
- if (calendarId) args.push(calendarId);
112
+ const args: GogArg[] = ['calendar', 'events'];
113
+ if (calendarId) args.push(pos(calendarId));
112
114
  if (from) args.push(`--from=${from}`);
113
115
  if (to) args.push(`--to=${to}`);
114
116
  if (days !== undefined) args.push(`--days=${days}`);
@@ -141,7 +143,7 @@ export function registerCalendarTools(server: McpServer): void {
141
143
  account: accountParam,
142
144
  }),
143
145
  }, async ({ calendarId, eventId, timezone, account }) => {
144
- const args = ['calendar', 'event', calendarId, eventId];
146
+ const args: GogArg[] = ['calendar', 'event', pos(calendarId), pos(eventId)];
145
147
  if (timezone) args.push(`--timezone=${timezone}`);
146
148
  return runOrDiagnose(args, { account });
147
149
  });
@@ -164,7 +166,7 @@ export function registerCalendarTools(server: McpServer): void {
164
166
  account: accountParam,
165
167
  }),
166
168
  }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, timezone, withZoom, reminders, noReminders, account }) => {
167
- const args = ['calendar', 'create', calendarId, `--summary=${summary}`, `--from=${from}`, `--to=${to}`];
169
+ const args: GogArg[] = ['calendar', 'create', pos(calendarId), `--summary=${summary}`, `--from=${from}`, `--to=${to}`];
168
170
  if (description) args.push(`--description=${description}`);
169
171
  if (location) args.push(`--location=${location}`);
170
172
  if (attendees) args.push(`--attendees=${attendees}`);
@@ -197,7 +199,7 @@ export function registerCalendarTools(server: McpServer): void {
197
199
  account: accountParam,
198
200
  }),
199
201
  }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, addAttendees, attachments, withZoom, regenerateZoom, removeZoom, removeMeet, reminders, noReminders, account }) => {
200
- const args = ['calendar', 'update', calendarId, eventId];
202
+ const args: GogArg[] = ['calendar', 'update', pos(calendarId), pos(eventId)];
201
203
  if (summary !== undefined) args.push(`--summary=${summary}`);
202
204
  if (from !== undefined) args.push(`--from=${from}`);
203
205
  if (to !== undefined) args.push(`--to=${to}`);
@@ -225,7 +227,7 @@ export function registerCalendarTools(server: McpServer): void {
225
227
  }, async ({ calendarId, eventId, account }) => {
226
228
  // gog gates this delete behind a confirmation; the runner injects
227
229
  // --no-input, so without --force it refuses at runtime.
228
- return runOrDiagnose(['calendar', 'delete', calendarId, eventId, '--force'], { account });
230
+ return runOrDiagnose(['calendar', 'delete', pos(calendarId), pos(eventId), '--force'], { account });
229
231
  });
230
232
 
231
233
  server.registerTool('gog_calendar_respond', {
@@ -239,7 +241,7 @@ export function registerCalendarTools(server: McpServer): void {
239
241
  account: accountParam,
240
242
  }),
241
243
  }, async ({ calendarId, eventId, status, comment, account }) => {
242
- const args = ['calendar', 'respond', calendarId, eventId, `--status=${status}`];
244
+ const args: GogArg[] = ['calendar', 'respond', pos(calendarId), pos(eventId), `--status=${status}`];
243
245
  if (comment) args.push(`--comment=${comment}`);
244
246
  return runOrDiagnose(args, { account });
245
247
  });