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
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp",
5
5
  "display_name": "gogcli",
6
- "version": "4.2.5",
6
+ "version": "4.4.0",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -37,7 +37,8 @@
37
37
  ],
38
38
  "env": {
39
39
  "GOG_ACCOUNT": "${user_config.gog_account}",
40
- "GOG_PATH": "${user_config.gog_path}"
40
+ "GOG_PATH": "${user_config.gog_path}",
41
+ "GOG_FILE_ROOTS": "${user_config.gog_file_roots}"
41
42
  }
42
43
  }
43
44
  },
@@ -53,6 +54,12 @@
53
54
  "title": "gog Executable Path",
54
55
  "description": "Path to the gog executable (optional — defaults to 'gog' on your PATH)",
55
56
  "required": false
57
+ },
58
+ "gog_file_roots": {
59
+ "type": "string",
60
+ "title": "Allowed file directories",
61
+ "description": "Directories tools may read files from (attachments, uploads) or write files to (exports, downloads), separated by ':' (';' on Windows). Optional — defaults to ~/gogcli-mcp-files",
62
+ "required": false
56
63
  }
57
64
  },
58
65
  "tools": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "4.2.5",
3
+ "version": "4.4.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp",
5
5
  "description": "MCP server wrapping gogcli for Google service access",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -41,7 +41,7 @@
41
41
  "test:coverage": "vitest run --coverage"
42
42
  },
43
43
  "dependencies": {
44
- "@chrischall/mcp-utils": "^2.1.0",
44
+ "@chrischall/mcp-utils": "^2.4.0",
45
45
  "@modelcontextprotocol/server": "^2.0.0",
46
46
  "zod": "^4.6.1"
47
47
  },
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "4.2.5",
10
+ "version": "4.4.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "4.2.5",
15
+ "version": "4.4.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -0,0 +1,68 @@
1
+ // Guards for argv this wrapper does NOT build itself.
2
+ //
3
+ // The `gog_<service>_run` escape hatches forward a model-supplied string[]
4
+ // verbatim. gog (kong) takes the LAST value of a repeated flag, so an arg like
5
+ // `--readonly=false` placed after the runner's injected `--readonly` silently
6
+ // re-enables writes on a deployment the operator locked read-only — the one
7
+ // kill switch an operator can set (audit SEC-1). The same holds for every
8
+ // other global that is a control rather than an option: the command allow/deny
9
+ // lists, the credential source (`--access-token`, `--home`, `--client`), the
10
+ // account, `--gmail-no-send` and `--no-input`.
11
+ //
12
+ // A bare `--` is refused as well. It ends flag parsing, so anything the wrapper
13
+ // appends AFTER it (a safety flag appended last, for instance) would be read as
14
+ // a positional and quietly dropped.
15
+ //
16
+ // Kept out of runner.ts on purpose: tool tests automock the runner module, and
17
+ // these checks have to run for real inside the tool handlers.
18
+
19
+ // Long-flag names whose override the model must never control, matched as
20
+ // EXACT names: the flag alone (`--readonly`) or with an attached value
21
+ // (`--readonly=false`), case-insensitively. Not as bare prefixes — that refused
22
+ // legitimate command flags that merely share a leading word, such as
23
+ // gog_zoom_auth_setup's --account-id / --client-id / --client-secret. gog has
24
+ // no flag abbreviation, so a longer spelling is a different flag, not an
25
+ // override.
26
+ const FORBIDDEN_LONG_FLAG = /^--(readonly|enable-commands|enable-commands-exact|disable-commands|access-token|home|account|acct|client|gmail-no-send|no-input|non-interactive|noninteractive)(=|$)/i;
27
+
28
+ // `-a` is the short form of --account. kong accepts short-flag CLUSTERS
29
+ // (`-ja` = `-j -a`) and an attached value (`-aother@x`), so any single-dash
30
+ // token whose letters include `a` is refused. `-5` (a negative number) and
31
+ // `-y` (force) are unaffected.
32
+ const SHORT_ACCOUNT_CLUSTER = /^-(?!-)[A-Za-z]*a/;
33
+
34
+ /** Why `arg` may not be forwarded to gog, or undefined when it is fine. */
35
+ export function forbiddenArgReason(arg: string): string | undefined {
36
+ if (arg === '--') {
37
+ return 'The argument "--" is not allowed: it ends flag parsing and would disable safety flags appended after it.';
38
+ }
39
+ const match = FORBIDDEN_LONG_FLAG.exec(arg);
40
+ if (match) {
41
+ return `The flag ${arg} is not allowed here: --${match[1].toLowerCase()} is a safety/credential control set by the server operator, not a per-call option.`;
42
+ }
43
+ if (SHORT_ACCOUNT_CLUSTER.test(arg)) {
44
+ return `The flag ${arg} is not allowed here: -a selects the account. Use the tool's account parameter instead.`;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /** Throw on the first model-supplied arg that would override a gog safety control. */
50
+ export function assertSafeForwardedArgs(args: readonly string[]): void {
51
+ for (const arg of args) {
52
+ const reason = forbiddenArgReason(arg);
53
+ if (reason) throw new Error(reason);
54
+ }
55
+ }
56
+
57
+ // A gog subcommand is a lower-case word, possibly hyphenated (`mark-read`).
58
+ // Anything else — a flag, an empty string, a path — is a smuggling attempt or a
59
+ // mistake, and gog would parse it as something other than a subcommand.
60
+ const SUBCOMMAND_SHAPE = /^[a-z][a-z0-9-]*$/;
61
+
62
+ export function assertSafeSubcommand(subcommand: string): void {
63
+ if (!SUBCOMMAND_SHAPE.test(subcommand)) {
64
+ throw new Error(
65
+ `Invalid subcommand ${JSON.stringify(subcommand)}: pass a single gog subcommand name such as "list" or "mark-read", and put its arguments in args.`,
66
+ );
67
+ }
68
+ }
package/src/argv.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Positional-argument marking for gog argv (audit BUG-1).
2
+ //
3
+ // A tool's positional values — a Gmail query, a file ID, a range, a name — are
4
+ // chosen by the model. Pushed as bare argv elements, one that starts with '-'
5
+ // is parsed by gog (kong) as a flag: `gog gmail search "-in:spam"` fails with
6
+ // "unknown flag -i", and a value starting with `--` becomes a real gog flag.
7
+ // Wrapping each value in pos() tells the runner to place it after a single
8
+ // `--`, after every flag, which is how upstream gogcli's own MCP tools call it
9
+ // (internal/cmd/mcp_tools.go).
10
+ //
11
+ // Lives outside runner.ts on purpose: tool tests automock the runner module,
12
+ // and pos() must stay a real function there.
13
+
14
+ export interface GogPositional {
15
+ /** Discriminant separating this from a plain argv string and a GogFileArg. */
16
+ kind: 'positional';
17
+ value: string;
18
+ }
19
+
20
+ /** Mark a model-supplied positional value so it is passed after `--`. */
21
+ export function pos(value: string): GogPositional {
22
+ return { kind: 'positional', value };
23
+ }
24
+
25
+ export function isGogPositional(arg: unknown): arg is GogPositional {
26
+ return typeof arg === 'object' && arg !== null && (arg as { kind?: unknown }).kind === 'positional';
27
+ }
@@ -0,0 +1,111 @@
1
+ // The private, self-cleaning directory attachment downloads land in (audit SEC-6).
2
+ //
3
+ // It used to be a fixed `/tmp/gog-attachments` that nothing ever emptied, so
4
+ // every downloaded attachment — medical records, statements, custody documents
5
+ // — piled up in world-traversable /tmp for the life of the host. And because
6
+ // the name was predictable and shared, another local user could pre-create it
7
+ // or plant a symlink there, redefining where the blob-upload "confinement"
8
+ // root pointed.
9
+ //
10
+ // Now the root is per-user (named for the uid, under the OS temp dir), created
11
+ // owner-only (0700), and verified before every use: a symlink, a non-directory
12
+ // or a directory owned by someone else is refused rather than trusted. Files a
13
+ // caller never sees again (inline, Drive and URL deliveries) are deleted as
14
+ // soon as they are delivered; files returned BY PATH are kept for the caller to
15
+ // read and swept once they are older than ATTACHMENT_TTL_MS.
16
+
17
+ import { tmpdir, userInfo } from 'node:os';
18
+ import { dirname, join, relative, resolve, isAbsolute, sep } from 'node:path';
19
+
20
+ /** Where gmail attachment downloads are written, and the only tree the blob upload reads from. */
21
+ export const ATTACHMENT_DOWNLOAD_ROOT = join(tmpdir(), `gogcli-mcp-attachments-${userInfo().uid}`);
22
+
23
+ /** How long a download returned by path is kept before a later download sweeps it. */
24
+ export const ATTACHMENT_TTL_MS = 24 * 60 * 60 * 1000;
25
+
26
+ function isInside(root: string, target: string): boolean {
27
+ const rel = relative(root, target);
28
+ return rel !== '' && !rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel);
29
+ }
30
+
31
+ /**
32
+ * Create `dir` owner-only if missing, then verify it: a real directory (not a
33
+ * symlink), owned by this user, with no group/other permissions (tightened to
34
+ * 0700 when it is ours but looser). `opts.uid` is injected by tests.
35
+ */
36
+ export async function ensurePrivateDir(dir: string, opts: { uid?: number } = {}): Promise<void> {
37
+ const { mkdir, lstat, chmod } = await import('node:fs/promises');
38
+ try {
39
+ await mkdir(dir, { mode: 0o700 });
40
+ } catch (err) {
41
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
42
+ }
43
+ const info = await lstat(dir);
44
+ if (info.isSymbolicLink() || !info.isDirectory()) {
45
+ throw new Error(`refusing to use ${dir} for attachment downloads: it is not a real directory`);
46
+ }
47
+ const uid = opts.uid ?? userInfo().uid;
48
+ if (info.uid !== uid) {
49
+ throw new Error(`refusing to use ${dir} for attachment downloads: it is owned by another user`);
50
+ }
51
+ if ((info.mode & 0o077) !== 0) await chmod(dir, 0o700);
52
+ }
53
+
54
+ /**
55
+ * Delete top-level entries of `root` last modified more than `ttlMs` ago.
56
+ * Best-effort: a missing root sweeps nothing, and one entry failing never stops
57
+ * the rest. Returns how many entries were removed.
58
+ */
59
+ export async function sweepExpiredDownloads(
60
+ root: string = ATTACHMENT_DOWNLOAD_ROOT,
61
+ ttlMs: number = ATTACHMENT_TTL_MS,
62
+ now: number = Date.now(),
63
+ ): Promise<number> {
64
+ const { readdir, lstat, rm } = await import('node:fs/promises');
65
+ let entries: string[];
66
+ try {
67
+ entries = await readdir(root);
68
+ } catch {
69
+ return 0;
70
+ }
71
+ let removed = 0;
72
+ for (const name of entries) {
73
+ const path = join(root, name);
74
+ try {
75
+ const info = await lstat(path);
76
+ if (now - info.mtimeMs > ttlMs) {
77
+ await rm(path, { recursive: true, force: true });
78
+ removed += 1;
79
+ }
80
+ } catch {
81
+ // raced with another sweep or a delivery cleanup — nothing to do
82
+ }
83
+ }
84
+ return removed;
85
+ }
86
+
87
+ /**
88
+ * Delete one delivered download, then any directories it leaves empty, up to
89
+ * (never including) `root`. A path outside `root` is left alone: this only
90
+ * ever cleans up after the server's own downloads, never a caller's `out`.
91
+ */
92
+ export async function removeDownload(path: string, root: string = ATTACHMENT_DOWNLOAD_ROOT): Promise<void> {
93
+ const { rm, rmdir } = await import('node:fs/promises');
94
+ const base = resolve(root);
95
+ const target = resolve(base, path);
96
+ if (!isInside(base, target)) return;
97
+ await rm(target, { force: true });
98
+ for (let dir = dirname(target); isInside(base, dir); dir = dirname(dir)) {
99
+ try {
100
+ await rmdir(dir);
101
+ } catch {
102
+ break; // not empty (or already gone): stop climbing
103
+ }
104
+ }
105
+ }
106
+
107
+ /** Make the download root private and sweep expired downloads out of it. */
108
+ export async function prepareDownloadRoot(root: string = ATTACHMENT_DOWNLOAD_ROOT): Promise<void> {
109
+ await ensurePrivateDir(root);
110
+ await sweepExpiredDownloads(root);
111
+ }
@@ -80,6 +80,7 @@ export const MAX_INLINE_ATTACHMENT_TOTAL_BYTES = Math.floor((MAX_REQUEST_PAYLOAD
80
80
  */
81
81
  function wireBytesOf(arg: GogArg): number {
82
82
  if (typeof arg === 'string') return Buffer.byteLength(arg, 'utf8');
83
+ if (arg.kind === 'positional') return Buffer.byteLength(arg.value, 'utf8');
83
84
  return arg.encoding === 'base64' ? arg.contents.length : Buffer.byteLength(arg.contents, 'utf8');
84
85
  }
85
86
 
@@ -25,9 +25,11 @@
25
25
  /**
26
26
  * Where gmail's attachment download writes (`defaultOutPath` in
27
27
  * packages/gogcli-mcp-gmail/src/tools/gmail-extra.ts) and the only tree this
28
- * module will read back from.
28
+ * module will read back from. Per-user and private — see attachment-root.ts.
29
29
  */
30
- export const ATTACHMENT_DOWNLOAD_ROOT = '/tmp/gog-attachments';
30
+ import { ATTACHMENT_DOWNLOAD_ROOT } from './attachment-root.js';
31
+
32
+ export { ATTACHMENT_DOWNLOAD_ROOT };
31
33
 
32
34
  /** mcp-host's blob store caps one object at 100 MiB and answers 413 past it. */
33
35
  export const MAX_BLOB_UPLOAD_BYTES = 100 * 1024 * 1024;
@@ -0,0 +1,277 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
4
+ import { callerAcceptsFormElicitation, readEnvVar, requireConfirmation, textResult } from '@chrischall/mcp-utils';
5
+ import { z } from 'zod';
6
+ import {
7
+ confirmTokenTtlSeconds,
8
+ hashSendPayload,
9
+ issueConfirmToken,
10
+ sendConfirmFallbackEnabled,
11
+ verifyConfirmToken,
12
+ type ConfirmBinding,
13
+ type ConfirmTokenError,
14
+ } from './send-confirm-token.js';
15
+
16
+ // ============================================================================
17
+ // THE DISPATCH RAIL, service-neutral: every tool that reaches another person
18
+ // (Gmail sends, Chat posts, guest-visible Calendar changes, Drive shares,
19
+ // Classroom announcements and invitations) asks the user first.
20
+ //
21
+ // Elicitation is primary: the host shows the preview and the model never holds
22
+ // the approval. On a client that declares no elicitation (claude.ai, measured),
23
+ // the call is refused — unless the call site supplies a `DispatchTokenFallback`
24
+ // and the server opted in with GOG_SEND_CONFIRM_FALLBACK=token, in which case
25
+ // the two-phase preview + confirmToken flow (send-confirm-token.ts) runs instead.
26
+ // ============================================================================
27
+
28
+ // Bound on the body text shown in a confirmation prompt. Enough to read what is
29
+ // actually being sent (the point of SEC-5), small enough to keep the prompt a
30
+ // prompt rather than a copy of the message.
31
+ export const BODY_PREVIEW_MAX = 2048;
32
+
33
+ /** The first BODY_PREVIEW_MAX characters of a body, marked when cut. */
34
+ export function bodyPreview(text: string | undefined): string | undefined {
35
+ if (!text) return undefined;
36
+ if (text.length <= BODY_PREVIEW_MAX) return text;
37
+ return `${text.slice(0, BODY_PREVIEW_MAX)}… [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
38
+ }
39
+
40
+ /**
41
+ * Every attachment a dispatch will carry: a server path in full (so the user
42
+ * sees WHICH file on the gog host is leaving), an inline one by its filename.
43
+ */
44
+ export function attachmentNames(
45
+ paths: readonly string[] | undefined,
46
+ inline: ReadonlyArray<{ filename: string }> | undefined,
47
+ ): string[] {
48
+ return [...(paths ?? []), ...(inline ?? []).map((a) => a.filename)];
49
+ }
50
+
51
+ /** One attachment as the fallback preview shows it and its hash binds it. */
52
+ export type AttachmentDetail = { name: string; size: number | null; sha256?: string };
53
+
54
+ /**
55
+ * Name, byte size and content SHA-256 of every file a dispatch carries, for the
56
+ * token fallback's preview and payload hash. A server path is read (it is
57
+ * already confined to GOG_FILE_ROOTS) so a same-size swap between the phases is
58
+ * still a changed payload; an unreadable one reports `size: null` and gog will
59
+ * fail on it anyway.
60
+ */
61
+ export function attachmentDetails(
62
+ paths: readonly string[] | undefined,
63
+ inline: ReadonlyArray<{ filename: string; contentBase64: string }> | undefined,
64
+ ): AttachmentDetail[] {
65
+ const out: AttachmentDetail[] = [];
66
+ for (const path of paths ?? []) {
67
+ let bytes: Buffer | undefined;
68
+ try {
69
+ bytes = readFileSync(path);
70
+ } catch {
71
+ bytes = undefined;
72
+ }
73
+ out.push(bytes
74
+ ? { name: path, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }
75
+ : { name: path, size: null });
76
+ }
77
+ for (const a of inline ?? []) {
78
+ out.push({
79
+ name: a.filename,
80
+ size: Buffer.from(a.contentBase64, 'base64').length,
81
+ sha256: createHash('sha256').update(a.contentBase64).digest('hex'),
82
+ });
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** Who a dispatch goes out as, for the fallback preview: an explicit alias, else the account. */
88
+ export function senderPreview(account: string | undefined, from?: string): string {
89
+ return from ?? account ?? readEnvVar('GOG_ACCOUNT') ?? "the gog account's default address";
90
+ }
91
+
92
+ /** Drop the fingerprint for display: the user needs a name and a size. */
93
+ export function attachmentPreview(details: readonly AttachmentDetail[]): Array<{ name: string; size: number | null }> {
94
+ return details.map(({ name, size }) => ({ name, size }));
95
+ }
96
+
97
+ /** The instruction a mail dispatch's phase 1 carries — verbatim from the spec that introduced the fallback. */
98
+ export const CONFIRM_SEND_INSTRUCTION =
99
+ 'Show this preview to the user verbatim and send only after they explicitly approve in chat. '
100
+ + 'Then call again with confirmToken.';
101
+
102
+ /** The same instruction for a dispatch that is not mail (a share, an invitation, a post). */
103
+ export const CONFIRM_ACTION_INSTRUCTION =
104
+ 'Show this preview to the user verbatim and proceed only after they explicitly approve in chat. '
105
+ + 'Then call again with confirmToken.';
106
+
107
+ const FALLBACK_HINT = 'Or set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation.';
108
+
109
+ /** Appended to each gated tool's description. */
110
+ export const CONFIRM_FALLBACK_DESCRIPTION =
111
+ ' If the client cannot show that prompt (no MCP elicitation, e.g. claude.ai) and the server sets '
112
+ + 'GOG_SEND_CONFIRM_FALLBACK=token, a two-step flow applies instead: call WITHOUT confirmToken and nothing is '
113
+ + 'sent or changed — the result has status "confirmation-required", the full preview and a confirmToken. Show that preview '
114
+ + 'to the user verbatim; only after they explicitly approve it in chat, call again with the SAME arguments plus '
115
+ + 'confirmToken. The tool re-reads what it would act on and refuses (DRAFT_CHANGED, with a fresh preview and token) '
116
+ + 'if it changed; TOKEN_EXPIRED / TOKEN_REUSED / TOKEN_INVALID also do nothing.';
117
+
118
+ export const confirmTokenParam = z.string().optional().describe(
119
+ 'ONLY for the two-step fallback (client without MCP elicitation, server with GOG_SEND_CONFIRM_FALLBACK=token). '
120
+ + 'The confirmToken from this same tool\'s phase-1 "confirmation-required" response, passed back ONLY after the user '
121
+ + 'has seen that preview and explicitly approved it in chat — never on the first call, never invented, never '
122
+ + 'reused. Call again with the same arguments. Ignored when the client supports elicitation.',
123
+ );
124
+
125
+ /** What the fallback binds a token to — recomputed from a fresh read on every call. */
126
+ export interface TokenSubject {
127
+ /** The draftId / messageId / fileId / eventId / query the dispatch acts on. */
128
+ target: string;
129
+ /** A version that rotates on edit: a draft's messageId, an event's etag. */
130
+ revision?: string;
131
+ /** Canonical send payload; its SHA-256 is bound into the token. */
132
+ payload: unknown;
133
+ /** The complete preview shown to the user. */
134
+ preview: Record<string, unknown>;
135
+ }
136
+
137
+ /**
138
+ * Opt-in second rail for a client that cannot be prompted. `subject` is only
139
+ * called when the fallback actually runs, so a tool may do an extra read there
140
+ * without changing the elicitation path at all. It may return an error result
141
+ * (a failed read), which is passed back unchanged.
142
+ */
143
+ export interface DispatchTokenFallback {
144
+ tool: string;
145
+ account?: string;
146
+ confirmToken?: string;
147
+ /** Phase 1's instruction to the model. Defaults to {@link CONFIRM_ACTION_INSTRUCTION}. */
148
+ instruction?: string;
149
+ subject: () => TokenSubject | CallToolResult | Promise<TokenSubject | CallToolResult>;
150
+ }
151
+
152
+ const TOKEN_ERROR_NOTE: Record<Exclude<ConfirmTokenError, 'DRAFT_CHANGED'>, string> = {
153
+ TOKEN_EXPIRED: 'Nothing was sent or changed: the confirmToken expired. Call again WITHOUT confirmToken for a fresh preview, '
154
+ + 'and ask the user to approve it again.',
155
+ TOKEN_REUSED: 'Nothing was sent or changed by this call: this confirmToken was already used, and one approval acts once. '
156
+ + 'If doing it again is really intended, call again WITHOUT confirmToken and get a new approval.',
157
+ TOKEN_INVALID: 'Nothing was sent or changed: this confirmToken was not issued by this server for this tool, account and '
158
+ + 'target (or the server has restarted since). Call again WITHOUT confirmToken for a fresh preview and approval.',
159
+ };
160
+
161
+ const DRAFT_CHANGED_NOTE = {
162
+ 'message-id-rotated': 'Nothing was sent or changed: the target was edited since the user approved it (a draft\'s '
163
+ + 'messageId or an event\'s version rotated), so what would happen is not what they saw.',
164
+ 'payload-changed': 'Nothing was sent or changed: what would happen no longer matches what the user approved.',
165
+ } as const;
166
+
167
+ function isToolResult(value: TokenSubject | CallToolResult): value is CallToolResult {
168
+ return Array.isArray((value as CallToolResult).content);
169
+ }
170
+
171
+ function rejection(data: Record<string, unknown>): CallToolResult {
172
+ return { ...textResult({ status: 'confirmation-rejected', confirmed: false, dispatched: false, ...data }), isError: true };
173
+ }
174
+
175
+ async function tokenConfirmation(op: string, fallback: DispatchTokenFallback): Promise<CallToolResult | undefined> {
176
+ const subject = await fallback.subject();
177
+ if (isToolResult(subject)) return subject;
178
+ const binding: ConfirmBinding = {
179
+ tool: fallback.tool,
180
+ account: fallback.account ?? readEnvVar('GOG_ACCOUNT') ?? '',
181
+ target: subject.target,
182
+ ...(subject.revision === undefined ? {} : { revision: subject.revision }),
183
+ payloadHash: hashSendPayload(subject.payload),
184
+ };
185
+ const phaseOne = () => {
186
+ const { token, expiresAt } = issueConfirmToken(binding);
187
+ return {
188
+ action: op,
189
+ preview: subject.preview,
190
+ confirmToken: token,
191
+ expiresAt,
192
+ ttlSeconds: confirmTokenTtlSeconds(),
193
+ instruction: fallback.instruction ?? CONFIRM_ACTION_INSTRUCTION,
194
+ };
195
+ };
196
+ if (!fallback.confirmToken) {
197
+ return textResult({ status: 'confirmation-required', confirmed: false, dispatched: false, ...phaseOne() });
198
+ }
199
+ const verdict = verifyConfirmToken(fallback.confirmToken, binding);
200
+ if (verdict.ok) return undefined;
201
+ if (verdict.error === 'DRAFT_CHANGED') {
202
+ return rejection({
203
+ error: 'DRAFT_CHANGED',
204
+ reason: verdict.reason,
205
+ note: `${DRAFT_CHANGED_NOTE[verdict.reason!]} The current preview and a fresh confirmToken are below.`,
206
+ ...phaseOne(),
207
+ });
208
+ }
209
+ return rejection({ error: verdict.error, action: op, note: TOKEN_ERROR_NOTE[verdict.error] });
210
+ }
211
+
212
+ /**
213
+ * The refusal an escape hatch (`gog_<service>_run`, `gog_api_call`) gives for an
214
+ * action a dedicated tool gates. Without it, the run tool is a way around the
215
+ * rail: the model forwards the same subcommand and nobody is asked (#400).
216
+ */
217
+ export function gatedElsewhere(what: string, via: string, does: string, tool: string): string {
218
+ return `${what} ${does} and is not available through ${via}. Use ${tool}, which asks the user to confirm.`;
219
+ }
220
+
221
+ /** True when any forwarded token is one of `words` (kong lets flags precede the command word). */
222
+ export function hasCommandWord(args: readonly string[], words: ReadonlySet<string>): string | undefined {
223
+ return args.find((a) => words.has(a.toLowerCase()));
224
+ }
225
+
226
+ export interface DispatchConfirmationOptions {
227
+ /** Stable id of the dispatch, echoed in every result (`gmail.send`, `drive.share`, …). */
228
+ action: string;
229
+ /** Heading of the elicitation prompt. */
230
+ message: string;
231
+ /** Label beside the prompt's confirmation checkbox. */
232
+ confirmationLabel: string;
233
+ /** What the elicitation prompt shows. */
234
+ details: Record<string, unknown>;
235
+ /** The way through on a client that cannot be prompted, when there is one. */
236
+ unsupportedNote?: string;
237
+ /** Opt-in second rail; omit it and a client that cannot be prompted is always refused. */
238
+ fallback?: DispatchTokenFallback;
239
+ }
240
+
241
+ /**
242
+ * Ask the user before a dispatch. `undefined` means proceed; anything else is
243
+ * the result to return unchanged.
244
+ *
245
+ * Elicitation stays the primary path and is untouched. Only when the caller
246
+ * declares it cannot be prompted AND a `fallback` is supplied AND
247
+ * GOG_SEND_CONFIRM_FALLBACK=token does the two-phase token flow run instead of
248
+ * the refusal; with the env unset, the refusal names that switch.
249
+ */
250
+ export async function requireDispatchConfirmation(
251
+ ctx: ServerContext,
252
+ options: DispatchConfirmationOptions,
253
+ ): Promise<InputRequiredResult | CallToolResult | undefined> {
254
+ const { action, fallback } = options;
255
+ if (fallback && callerAcceptsFormElicitation(ctx) === false && sendConfirmFallbackEnabled()) {
256
+ return tokenConfirmation(action, fallback);
257
+ }
258
+ const note = fallback
259
+ ? [options.unsupportedNote, FALLBACK_HINT].filter(Boolean).join(' ')
260
+ : options.unsupportedNote;
261
+ return requireConfirmation(ctx, {
262
+ action,
263
+ message: options.message,
264
+ details: options.details,
265
+ confirmationLabel: options.confirmationLabel,
266
+ ...(note ? { unsupportedNote: note } : {}),
267
+ });
268
+ }
269
+
270
+ // The single place a CallToolResult's text is pulled back out, for the tools
271
+ // here that need to read gog's own JSON before deciding what to preview or
272
+ // log. Mirrors the shape every runOrDiagnose result actually returns
273
+ // (content[0].text); never throws on an unexpected shape.
274
+ export function resultText(result: CallToolResult): string {
275
+ const first = result.content[0];
276
+ return first && first.type === 'text' && typeof first.text === 'string' ? first.text : '{}';
277
+ }
@@ -0,0 +1,66 @@
1
+ // Operator-configured confinement for every server-side path a tool accepts
2
+ // (audit SEC-3/SEC-4).
3
+ //
4
+ // `attach`, `localPath`, `file`, `out`, `outDir` and friends are resolved on the
5
+ // machine gog runs on, and the model chooses them. Unconfined, each is a read
6
+ // primitive (attach ~/.ssh/id_rsa, gog's credentials, /proc/<pid>/environ to an
7
+ // email) or a write primitive (drop attacker-chosen attachment bytes on
8
+ // ~/Library/LaunchAgents/x.plist or ~/.zshrc). Every such path must now resolve
9
+ // — through symlinks, via mcp-utils' assertPathWithinRoots — inside one of the
10
+ // directories the operator lists in GOG_FILE_ROOTS, or the server's own private
11
+ // attachment download directory.
12
+ //
13
+ // GOG_FILE_ROOTS is a PATH-style list (':' on POSIX). Unset, it defaults to
14
+ // ~/gogcli-mcp-files: files to attach or upload go there, and exports and
15
+ // downloads with an explicit path must be written there. Set it to a wider
16
+ // directory (e.g. your home) deliberately, not by default.
17
+
18
+ import { homedir } from 'node:os';
19
+ import { delimiter, join } from 'node:path';
20
+ import { assertPathWithinRoots, readEnvVar } from '@chrischall/mcp-utils';
21
+ import { ATTACHMENT_DOWNLOAD_ROOT } from './attachment-root.js';
22
+
23
+ export const FILE_ROOTS_ENV = 'GOG_FILE_ROOTS';
24
+
25
+ /** The default root when GOG_FILE_ROOTS is unset. */
26
+ export function defaultFileRoot(): string {
27
+ return join(homedir(), 'gogcli-mcp-files');
28
+ }
29
+
30
+ /** The operator's configured roots, or the default one. */
31
+ export function fileRoots(): string[] {
32
+ const raw = readEnvVar(FILE_ROOTS_ENV);
33
+ const roots = (raw ?? '').split(delimiter).map((r) => r.trim()).filter(Boolean);
34
+ return roots.length > 0 ? roots : [defaultFileRoot()];
35
+ }
36
+
37
+ /**
38
+ * Require `path` (a model-supplied server path, named `param` in the error) to
39
+ * lie inside an allowed root. Returns it unchanged. `allowDash` admits gog's
40
+ * `-` for tools where it means stdout; anywhere else `-` means stdin, which this
41
+ * server never writes to, so it is refused like any other stray path.
42
+ */
43
+ export function confinePath(path: string, param: string, opts: { allowDash?: boolean } = {}): string {
44
+ if (opts.allowDash && path === '-') return path;
45
+ const roots = [...fileRoots(), ATTACHMENT_DOWNLOAD_ROOT];
46
+ try {
47
+ assertPathWithinRoots(path, roots);
48
+ } catch {
49
+ throw new Error(
50
+ `${param} ${JSON.stringify(path)} is outside the directories this server may read or write `
51
+ + `(${roots.join(', ')}). Put the file in one of them, or ask the server operator to widen ${FILE_ROOTS_ENV}.`,
52
+ );
53
+ }
54
+ return path;
55
+ }
56
+
57
+ /** confinePath for every element of an optional list. */
58
+ export function confinePaths(paths: readonly string[] | undefined, param: string): void {
59
+ for (const path of paths ?? []) confinePath(path, param);
60
+ }
61
+
62
+ /** For JSON-or-`@file` inputs: confine the path of an `@file` value, pass inline JSON through. */
63
+ export function confineAtFile(value: string, param: string): string {
64
+ if (value.startsWith('@')) confinePath(value.slice(1), param);
65
+ return value;
66
+ }