gogcli-mcp 4.2.4 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +714 -216
- package/dist/lib.js +751 -217
- package/manifest.json +9 -2
- package/mint.yaml +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/arg-guard.ts +68 -0
- package/src/argv.ts +27 -0
- package/src/attachment-root.ts +111 -0
- package/src/attachments.ts +1 -0
- package/src/blob-upload.ts +4 -2
- package/src/file-roots.ts +66 -0
- package/src/gmail-dispatch-guard.ts +56 -16
- package/src/gmail-results.ts +21 -2
- package/src/lib.ts +6 -0
- package/src/run-path-guard.ts +118 -0
- package/src/runner.ts +87 -19
- package/src/tools/api.ts +39 -7
- package/src/tools/appscript.ts +12 -8
- package/src/tools/auth.ts +13 -3
- package/src/tools/calendar.ts +10 -8
- package/src/tools/chat.ts +16 -13
- package/src/tools/classroom.ts +27 -25
- package/src/tools/contacts.ts +5 -3
- package/src/tools/docs.ts +8 -6
- package/src/tools/drive.ts +42 -18
- package/src/tools/gmail.ts +71 -11
- package/src/tools/sheets.ts +10 -8
- package/src/tools/slides.ts +14 -9
- package/src/tools/tasks.ts +7 -5
- package/src/tools/utils.ts +52 -6
- package/tests/arg-guard.test.ts +80 -0
- package/tests/attachment-root.test.ts +130 -0
- package/tests/attachments.test.ts +8 -0
- package/tests/blob-upload.test.ts +4 -3
- package/tests/file-roots.test.ts +101 -0
- package/tests/gmail-dispatch-guard.test.ts +46 -0
- package/tests/gmail-results.test.ts +35 -1
- package/tests/run-path-guard.test.ts +142 -0
- package/tests/runner.test.ts +136 -0
- package/tests/tools/api.test.ts +74 -8
- package/tests/tools/appscript.test.ts +34 -8
- package/tests/tools/auth.test.ts +44 -17
- package/tests/tools/calendar.test.ts +29 -27
- package/tests/tools/chat.test.ts +46 -20
- package/tests/tools/classroom.test.ts +39 -38
- package/tests/tools/contacts.test.ts +3 -2
- package/tests/tools/docs.test.ts +44 -15
- package/tests/tools/drive.test.ts +106 -19
- package/tests/tools/gmail.test.ts +227 -29
- package/tests/tools/run-tool-examples.test.ts +69 -0
- package/tests/tools/sheets.test.ts +16 -15
- package/tests/tools/slides.test.ts +47 -11
- package/tests/tools/tasks.test.ts +7 -6
- package/tests/tools/utils.test.ts +32 -31
- 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;
|
|
@@ -107,7 +120,7 @@ const TIMEOUT_MS = 30_000;
|
|
|
107
120
|
// so the requirement change is surfaced in the release notes (see
|
|
108
121
|
// .github/release.yml). This is the single source of truth for the required
|
|
109
122
|
// version; keep the README/CLAUDE.md mention in sync.
|
|
110
|
-
export const MIN_GOG_VERSION = '0.
|
|
123
|
+
export const MIN_GOG_VERSION = '0.41.0';
|
|
111
124
|
|
|
112
125
|
// Interpret the GOG_READONLY kill-switch. `readEnvVar` already treats blank
|
|
113
126
|
// values, 'undefined'/'null' sentinels, and unresolved .mcpb placeholders
|
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
):
|
|
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:
|
|
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(...
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/tools/appscript.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/tools/calendar.ts
CHANGED
|
@@ -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:
|
|
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
|
});
|