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
@@ -1,10 +1,27 @@
1
1
  import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
2
- import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
2
+ import { readEnvVar } from '@chrischall/mcp-utils';
3
+ import { CONFIRM_SEND_INSTRUCTION, requireDispatchConfirmation, type DispatchTokenFallback } from './dispatch-confirmation.js';
4
+
5
+ // The generic pieces moved to dispatch-confirmation.ts when Chat, Calendar,
6
+ // Drive and Classroom joined the rail; re-exported so Gmail callers keep one import.
7
+ export {
8
+ attachmentDetails,
9
+ attachmentNames,
10
+ BODY_PREVIEW_MAX,
11
+ bodyPreview,
12
+ attachmentPreview,
13
+ CONFIRM_FALLBACK_DESCRIPTION,
14
+ CONFIRM_SEND_INSTRUCTION as CONFIRM_INSTRUCTION,
15
+ confirmTokenParam,
16
+ resultText,
17
+ senderPreview,
18
+ } from './dispatch-confirmation.js';
19
+ export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './dispatch-confirmation.js';
3
20
 
4
21
  // ============================================================================
5
- // THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply are
6
- // the only tools in this fleet that put a message irreversibly into someone
7
- // else's mailbox on the FIRST call. Every other Gmail write either stages
22
+ // THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply /
23
+ // drafts_send, and a filter that forwards, are the tools in this fleet that put
24
+ // a message irreversibly into someone else's mailbox. Every other Gmail write either stages
8
25
  // something (drafts) or acts on mail already in this account (labels,
9
26
  // archive, trash). A caller that meant "save a draft" and picked the wrong
10
27
  // tool — or an agent that inherited the wrong reply target — used to find out
@@ -15,6 +32,12 @@ import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
15
32
  // user's accepted confirmation dispatches. The confirmation is never a tool
16
33
  // argument, so a model cannot bypass the user by setting a boolean itself.
17
34
  //
35
+ // The one exception is OPT-IN: with GOG_SEND_CONFIRM_FALLBACK=token, a client
36
+ // that cannot be prompted gets a two-phase preview + confirmToken instead of a
37
+ // refusal (`DispatchTokenFallback`, send-confirm-token.ts). There the approval
38
+ // IS a tool argument; see that file for exactly what the token does and does
39
+ // not guarantee.
40
+ //
18
41
  // A CLIENT THAT CANNOT SHOW THAT PROMPT gets a sentence rather than a prompt
19
42
  // it will refuse to deliver (`unsupportedNote`, mcp-utils `requireConfirmation`).
20
43
  // Measured on the mcp-host fleet 2026-09-20: claude.ai declares no MCP
@@ -26,7 +49,7 @@ import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
26
49
  // ============================================================================
27
50
 
28
51
  /**
29
- * The five dispatches this rail guards, spelled once.
52
+ * The dispatches this rail guards, spelled once.
30
53
  *
31
54
  * A UNION rather than `string`, because the staging-twin table below is keyed
32
55
  * by these values and a key that matches no call site is silent: it costs the
@@ -41,7 +64,14 @@ export type GmailDispatchOp =
41
64
  | 'gmail.reply'
42
65
  | 'gmail.reply-all'
43
66
  | 'gmail.forward'
44
- | 'gmail.autoreply';
67
+ | 'gmail.autoreply'
68
+ // Sending a staged draft dispatches mail just as irreversibly as a direct
69
+ // send; draft-create then drafts-send was an unconfirmed two-step around the
70
+ // rail (audit SEC-2).
71
+ | 'gmail.drafts-send'
72
+ // A filter with a forward action sends every FUTURE matching message to
73
+ // another address — persistent exfiltration, not a one-off send.
74
+ | 'gmail.filter-forward';
45
75
 
46
76
  /** Every op, for tests that must cover the set rather than a chosen member. */
47
77
  export const GMAIL_DISPATCH_OPS: readonly GmailDispatchOp[] = [
@@ -50,6 +80,8 @@ export const GMAIL_DISPATCH_OPS: readonly GmailDispatchOp[] = [
50
80
  'gmail.reply-all',
51
81
  'gmail.forward',
52
82
  'gmail.autoreply',
83
+ 'gmail.drafts-send',
84
+ 'gmail.filter-forward',
53
85
  ];
54
86
 
55
87
  /**
@@ -66,9 +98,9 @@ export function replyDispatchOp(kind: 'reply' | 'reply-all'): GmailDispatchOp {
66
98
 
67
99
  /**
68
100
  * The staging twin of each dispatch, named in the refusal above. Every one of
69
- * these saves without sending, and `gog_gmail_drafts_send` then dispatches it —
70
- * which is the rail's own sanctioned two-step (staging is visible and
71
- * inspectable, so the send is never the FIRST call), not a way around it.
101
+ * these saves without sending. `gog_gmail_drafts_send` asks for confirmation
102
+ * too, so on a client that cannot show a prompt the way through is the USER
103
+ * sending the saved draft from Gmail — a human in the loop either way.
72
104
  *
73
105
  * `Partial<Record<…>>` and not an index signature: a key outside the union is
74
106
  * now rejected by the compiler, which is the whole point, while `autoreply`
@@ -83,24 +115,41 @@ const STAGING_TWIN: Partial<Record<GmailDispatchOp, string>> = {
83
115
  'gmail.send': 'gog_gmail_drafts_create',
84
116
  };
85
117
 
86
- /** Apply the shared stateless confirmation flow with Gmail-specific copy. */
87
- export function requireGmailDispatchConfirmation(
118
+ // What to say when there is no staging twin but there IS still a way through.
119
+ const UNSUPPORTED_NOTE: Partial<Record<GmailDispatchOp, string>> = {
120
+ 'gmail.drafts-send': 'The draft is still saved: ask the user to review it and send it from Gmail.',
121
+ };
122
+
123
+ /**
124
+ * Apply the shared stateless confirmation flow with Gmail-specific copy.
125
+ *
126
+ * Elicitation stays the primary path and is untouched. Only when the caller
127
+ * declares it cannot be prompted AND a `fallback` is supplied AND
128
+ * GOG_SEND_CONFIRM_FALLBACK=token does the two-phase token flow run instead of
129
+ * the refusal; with the env unset, the refusal names that switch.
130
+ */
131
+ export async function requireGmailDispatchConfirmation(
88
132
  ctx: ServerContext,
89
133
  op: GmailDispatchOp,
90
134
  details: Record<string, unknown>,
91
- ): InputRequiredResult | CallToolResult | undefined {
135
+ fallback?: DispatchTokenFallback,
136
+ ): Promise<InputRequiredResult | CallToolResult | undefined> {
92
137
  const twin = STAGING_TWIN[op];
93
- return requireConfirmation(ctx, {
138
+ const note = twin
139
+ ? `Stage it with ${twin} instead; the user can review the draft and send it from Gmail `
140
+ + '(gog_gmail_drafts_send also asks for confirmation).'
141
+ : UNSUPPORTED_NOTE[op];
142
+ return requireDispatchConfirmation(ctx, {
94
143
  action: op,
95
- message: 'Review and confirm this email dispatch:',
144
+ message: op === 'gmail.filter-forward'
145
+ ? 'Review and confirm this mail-forwarding filter:'
146
+ : 'Review and confirm this email dispatch:',
96
147
  details,
97
- confirmationLabel: 'Confirm that this email should be sent now.',
98
- ...(twin
99
- ? {
100
- unsupportedNote: `Stage it with ${twin} instead, review the draft, `
101
- + 'and send it with gog_gmail_drafts_send.',
102
- }
103
- : {}),
148
+ confirmationLabel: op === 'gmail.filter-forward'
149
+ ? 'Confirm that matching mail should be forwarded automatically from now on.'
150
+ : 'Confirm that this email should be sent now.',
151
+ unsupportedNote: note,
152
+ ...(fallback ? { fallback: { instruction: CONFIRM_SEND_INSTRUCTION, ...fallback } } : {}),
104
153
  });
105
154
  }
106
155
 
@@ -170,12 +219,3 @@ export function logGmailDispatch(tool: string, recipients: string[], account?: s
170
219
  };
171
220
  process.stderr.write(`${JSON.stringify(event)}\n`);
172
221
  }
173
-
174
- // The single place a CallToolResult's text is pulled back out, for the tools
175
- // here that need to read gog's own JSON before deciding what to preview or
176
- // log. Mirrors the shape every runOrDiagnose result actually returns
177
- // (content[0].text); never throws on an unexpected shape.
178
- export function resultText(result: CallToolResult): string {
179
- const first = result.content[0];
180
- return first && first.type === 'text' && typeof first.text === 'string' ? first.text : '{}';
181
- }
@@ -3,6 +3,7 @@ import { rawTextResult } from '@chrischall/mcp-utils';
3
3
  import { run } from './runner.js';
4
4
  import { annotateTruncation, hasMorePages } from './pagination.js';
5
5
  import type { MatchCount } from './pagination.js';
6
+ import { pos } from './argv.js';
6
7
 
7
8
  // Post-processing for Gmail search output, on the seam between gog's JSON and
8
9
  // the model client. Two guarantees live here, both of which exist because a
@@ -102,7 +103,7 @@ async function countMatches(
102
103
  maxResults: COUNT_PROBE_PAGE_SIZE,
103
104
  fields: `${itemsKey}/id,nextPageToken`,
104
105
  });
105
- const raw = await run(['api', 'call', 'gmail', 'v1', method, `--params=${params}`], { account });
106
+ const raw = await run(['api', 'call', 'gmail', 'v1', pos(method), `--params=${params}`], { account });
106
107
  const parsed = JSON.parse(raw) as Record<string, unknown>;
107
108
  const items = parsed[itemsKey];
108
109
  if (!Array.isArray(items)) return {};
@@ -208,8 +209,13 @@ export async function fetchGmailPages(
208
209
  // Nothing collected yet means the caller should just see that result; once
209
210
  // pages ARE collected, return them WITH the cursor that was about to be
210
211
  // consumed, so the set still reads as truncated rather than complete.
212
+ // The failing page's own text rides along as `pageError` (audit QUAL-1):
213
+ // without it the caller sees only "more exist", retries the same failing
214
+ // page, and never learns WHY — e.g. that the account needs re-auth.
211
215
  if (parsed === undefined) {
212
- return base === undefined ? result : finish(base, itemsKey, merged, token);
216
+ return base === undefined
217
+ ? result
218
+ : finish(base, itemsKey, merged, token, pageErrorText(result, itemsKey, pages + 1));
213
219
  }
214
220
  base = parsed;
215
221
  merged.push(...(parsed[itemsKey] as unknown[]));
@@ -253,14 +259,27 @@ function parsePage(
253
259
  return Array.isArray(obj[itemsKey]) ? obj : undefined;
254
260
  }
255
261
 
262
+ // What went wrong on page `n` of a walk, bounded so a stray HTML page cannot
263
+ // become the payload. An error result is already diagnosed text (hints and
264
+ // all); anything else is output this walk could not read as a list.
265
+ const PAGE_ERROR_MAX = 2000;
266
+ function pageErrorText(result: CallToolResult, itemsKey: string, n: number): string {
267
+ const first = result.content[0];
268
+ const text = first?.type === 'text' ? first.text.slice(0, PAGE_ERROR_MAX) : '';
269
+ if (result.isError) return `page ${n} failed${text ? `: ${text}` : ''}`;
270
+ return `page ${n} returned output that is not a ${itemsKey} list`;
271
+ }
272
+
256
273
  function finish(
257
274
  base: Record<string, unknown>,
258
275
  itemsKey: string,
259
276
  merged: unknown[],
260
277
  token: string | undefined,
278
+ pageError?: string,
261
279
  ): CallToolResult {
262
280
  const out: Record<string, unknown> = { ...base, [itemsKey]: merged };
263
281
  if (token === undefined) delete out.nextPageToken;
264
282
  else out.nextPageToken = token;
283
+ if (pageError !== undefined) out.pageError = pageError;
265
284
  return rawTextResult(JSON.stringify(out));
266
285
  }
package/src/lib.ts CHANGED
@@ -26,11 +26,22 @@ export type { ReplyFlags } from './tools/gmail.js';
26
26
  // gmail sub-package's send-side forward/autoreply tools reuse these directly
27
27
  // rather than re-declaring the gate; the draft-side twins never import them.
28
28
  export {
29
+ attachmentDetails,
30
+ attachmentNames,
31
+ attachmentPreview,
32
+ bodyPreview,
33
+ CONFIRM_FALLBACK_DESCRIPTION,
34
+ confirmTokenParam,
29
35
  extractEmails,
30
36
  logGmailDispatch,
31
37
  requireGmailDispatchConfirmation,
32
38
  resultText,
39
+ senderPreview,
33
40
  } from './gmail-dispatch-guard.js';
41
+ // The service-neutral rail for every other tool that reaches another person.
42
+ export { requireDispatchConfirmation } from './dispatch-confirmation.js';
43
+ export { readCourse } from './tools/classroom.js';
44
+ export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './gmail-dispatch-guard.js';
34
45
  export { run, runBinary, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
35
46
  // Sub-package tools that read gog JSON through bare `run()` (rather than the
36
47
  // `runOrDiagnose` seam) must still apply this, or their timestamps skip the
@@ -45,6 +56,10 @@ export type { FinalizeOptions, GmailListMethod } from './gmail-results.js';
45
56
  export { bootstrapGogAuth, AUTH_BOOTSTRAP_MARKER } from './bootstrap-auth.js';
46
57
  export type { AuthBootstrapStatus, AuthBootstrapOptions } from './bootstrap-auth.js';
47
58
  export type { RunOptions, Spawner, GogArg, GogFileArg } from './runner.js';
59
+ export { pos } from './argv.js';
60
+ export { confinePath, confinePaths, confineAtFile, fileRoots, defaultFileRoot, FILE_ROOTS_ENV } from './file-roots.js';
61
+ export { prepareDownloadRoot, removeDownload, ATTACHMENT_TTL_MS } from './attachment-root.js';
62
+ export type { GogPositional } from './argv.js';
48
63
  // Caller-supplied attachment bytes — the only outbound attachment path that
49
64
  // works when the caller and gog share no filesystem (a hosted deployment such
50
65
  // as mcp-host). See src/attachments.ts.
@@ -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
  }