mcp-google-multi 6.0.1 → 6.1.0-alpha.1

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.
@@ -0,0 +1,239 @@
1
+ import { escapeHatchVerdict, writeDisabledResult } from '../write-control.js';
2
+ import { resolveOutboundAllowlist } from '../outbound-allowlist.js';
3
+ import { jsonResult } from '../executor.js';
4
+ // ROUND 7 (MEDIUM 3): every DISPLAYED string is run through this first.
5
+ // \p{Cc} (controls, incl. \n/\r/\t, which is fine: this only touches
6
+ // individual VALUES before they are embedded in the template, never the
7
+ // template's own line breaks), \p{Cf} (format, incl. bidi overrides),
8
+ // \p{Zl}/\p{Zp} (U+2028/U+2029) can otherwise forge what looks like a
9
+ // genuine new prompt line ("Body: (none)\nAccept to run it...") inside an
10
+ // attacker-controlled value. Escaped visibly rather than stripped, so the
11
+ // fake line reads as the literal escape text instead of silently vanishing.
12
+ const HIDDEN_CHAR_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
13
+ function sanitizeDisplay(value) {
14
+ return value.replace(HIDDEN_CHAR_RE, (ch) => `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`);
15
+ }
16
+ // ROUND 7 (HIGH 2): redact by KEY NAME, never by what a value looks like.
17
+ // The old value-shape regex (a long quoted token-looking string) is gone:
18
+ // it swallowed real destinations (Gmail raw, Drive parents) exactly as
19
+ // readily as a real secret, and nothing here ever sees a real secret in
20
+ // the first place (OAuth tokens live in the token store, never in a tool
21
+ // call's own arguments), so there is nothing to protect by value shape.
22
+ const SECRET_KEY_RE = /(token|secret|password|passwd|authoriz|api[_-]?key|private[_-]?key|credential)/i;
23
+ function redactSecretsByKey(value, seen = new WeakSet()) {
24
+ if (value === null || typeof value !== 'object')
25
+ return value;
26
+ if (seen.has(value))
27
+ return '[circular]';
28
+ seen.add(value);
29
+ if (Array.isArray(value))
30
+ return value.map((v) => redactSecretsByKey(v, seen));
31
+ const out = {};
32
+ for (const [k, v] of Object.entries(value)) {
33
+ out[k] = SECRET_KEY_RE.test(k) ? '[redacted]' : redactSecretsByKey(v, seen);
34
+ }
35
+ return out;
36
+ }
37
+ // ROUND 7 (HIGH 2): what the Destinations section looks for, recursively,
38
+ // across pathParams, queryParams and body. Two independent signals: the
39
+ // VALUE looks like an email or a URL (wherever it sits), or the KEY looks
40
+ // like it names a destination/target outright (a Drive parent, an owner,
41
+ // a path param naming the resource acted on) even when the value itself
42
+ // is just an opaque id with no recognizable shape of its own.
43
+ const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
44
+ const URL_RE = /\bhttps?:\/\/[^\s"'<>]+/gi;
45
+ const DESTINATION_KEY_RE = /^(parents?|folder[_-]?id|new[_-]?owner|owner|destination|target|recipient|delegate(email)?|forwarding(email)?|send[_-]?as(email)?|grantee|member|attendee|to|cc|bcc|email(address)?|address|file[_-]?id|calendar[_-]?id|user[_-]?id|space[_-]?id|document[_-]?id|spreadsheet[_-]?id|presentation[_-]?id|drive[_-]?id|group[_-]?id|customer[_-]?id|device[_-]?id)$/i;
46
+ const MAX_DESTINATION_LEN = 200;
47
+ function lastKeySegment(keyPath) {
48
+ const cleaned = keyPath.replace(/\[\d+\]/g, '');
49
+ const parts = cleaned.split('.');
50
+ return parts[parts.length - 1] ?? '';
51
+ }
52
+ function collectDestinations(value, keyPath, out, seen) {
53
+ if (value === null || value === undefined)
54
+ return;
55
+ if (typeof value === 'string') {
56
+ const lastKey = lastKeySegment(keyPath);
57
+ if (DESTINATION_KEY_RE.test(lastKey) && value.length > 0)
58
+ out.push(value);
59
+ for (const m of value.matchAll(EMAIL_RE))
60
+ out.push(m[0]);
61
+ for (const m of value.matchAll(URL_RE))
62
+ out.push(m[0]);
63
+ return;
64
+ }
65
+ if (typeof value !== 'object')
66
+ return;
67
+ if (seen.has(value))
68
+ return;
69
+ seen.add(value);
70
+ if (Array.isArray(value)) {
71
+ value.forEach((v, i) => collectDestinations(v, `${keyPath}[${i}]`, out, seen));
72
+ return;
73
+ }
74
+ for (const [k, v] of Object.entries(value)) {
75
+ collectDestinations(v, keyPath ? `${keyPath}.${k}` : k, out, seen);
76
+ }
77
+ }
78
+ // ROUND 7 (HIGH 2): a Gmail `raw` field is base64url-encoded RFC 822 MIME;
79
+ // the body summary otherwise hides its real recipients entirely. Found by
80
+ // KEY NAME anywhere in the body (gmail.users.messages.send carries it at
81
+ // the top level, gmail.users.drafts.update under message.raw). A `raw`
82
+ // that fails to decode, or decodes to something with no recognizable
83
+ // header block, cannot be summarized safely: the caller refuses rather
84
+ // than show a confirmation that might not describe the real destination.
85
+ const WANTED_HEADERS = ['to', 'cc', 'bcc', 'from', 'reply-to', 'subject'];
86
+ function decodeGmailRaw(raw) {
87
+ if (!/^[A-Za-z0-9_-]+$/.test(raw))
88
+ return null;
89
+ let decoded;
90
+ try {
91
+ decoded = Buffer.from(raw, 'base64url').toString('utf-8');
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ if (!decoded)
97
+ return null;
98
+ const headerBlockEnd = decoded.search(/\r?\n\r?\n/);
99
+ const headerBlock = headerBlockEnd === -1 ? decoded : decoded.slice(0, headerBlockEnd);
100
+ const headers = {};
101
+ let current = null;
102
+ for (const line of headerBlock.split(/\r?\n/)) {
103
+ // Space before the colon is legal older RFC syntax, and a repeated header
104
+ // must append: overwriting would hide one recipient from the confirmation.
105
+ const m = /^([A-Za-z-]+)[ \t]*:\s*(.*)$/.exec(line);
106
+ if (m && WANTED_HEADERS.includes(m[1].toLowerCase())) {
107
+ current = m[1].toLowerCase();
108
+ headers[current] = headers[current] ? `${headers[current]}, ${m[2]}` : m[2];
109
+ }
110
+ else if (current && /^[ \t]/.test(line)) {
111
+ headers[current] += ` ${line.trim()}`;
112
+ }
113
+ else {
114
+ current = null;
115
+ }
116
+ }
117
+ return Object.keys(headers).length > 0 ? headers : null;
118
+ }
119
+ /** Every `raw` string found anywhere in `value`, by key name. */
120
+ function collectRawFields(value, out, seen = new WeakSet()) {
121
+ if (value === null || typeof value !== 'object')
122
+ return;
123
+ if (seen.has(value))
124
+ return;
125
+ seen.add(value);
126
+ if (Array.isArray(value)) {
127
+ for (const v of value)
128
+ collectRawFields(v, out, seen);
129
+ return;
130
+ }
131
+ for (const [k, v] of Object.entries(value)) {
132
+ if (k === 'raw' && typeof v === 'string')
133
+ out.push(v);
134
+ else
135
+ collectRawFields(v, out, seen);
136
+ }
137
+ }
138
+ const MAX_BODY_CHARS = 500;
139
+ /** Builds the Destinations section and the (secret-key-redacted, length-
140
+ * capped) Body section for the confirmation prompt, or reports that the
141
+ * body cannot be summarized safely at all. Destinations are never cut:
142
+ * only the Body JSON is subject to the character cap. */
143
+ function buildSummary(info) {
144
+ const seen = new WeakSet();
145
+ const destinations = [];
146
+ collectDestinations(info.pathParams, 'pathParams', destinations, seen);
147
+ collectDestinations(info.queryParams, 'queryParams', destinations, seen);
148
+ collectDestinations(info.body, 'body', destinations, seen);
149
+ const rawFields = [];
150
+ collectRawFields(info.body, rawFields);
151
+ for (const raw of rawFields) {
152
+ const headers = decodeGmailRaw(raw);
153
+ if (!headers) {
154
+ return { ok: false, reason: 'the request body carries a "raw" field that could not be decoded as a MIME message' };
155
+ }
156
+ for (const [name, headerValue] of Object.entries(headers)) {
157
+ destinations.push(`${name}: ${headerValue}`);
158
+ }
159
+ }
160
+ const dedupedDestinations = [...new Set(destinations)].map((d) => sanitizeDisplay(d.length > MAX_DESTINATION_LEN ? `${d.slice(0, MAX_DESTINATION_LEN)}...` : d));
161
+ const destinationsText = dedupedDestinations.length > 0 ? dedupedDestinations.join('; ') : '(none found)';
162
+ let bodyText;
163
+ try {
164
+ bodyText = JSON.stringify(redactSecretsByKey(info.body)) ?? '(none)';
165
+ }
166
+ catch {
167
+ return { ok: false, reason: 'the request body could not be serialized for the confirmation prompt' };
168
+ }
169
+ bodyText = sanitizeDisplay(bodyText);
170
+ if (bodyText.length > MAX_BODY_CHARS) {
171
+ bodyText = `${bodyText.slice(0, MAX_BODY_CHARS)}... (truncated, ${bodyText.length} chars total)`;
172
+ }
173
+ return { ok: true, destinationsText, bodyText };
174
+ }
175
+ function buildPrompt(info, summary) {
176
+ return [
177
+ 'A write via the Google API escape hatch needs your confirmation.',
178
+ `Account: ${sanitizeDisplay(info.account)}${info.accountEmail ? ` (${sanitizeDisplay(info.accountEmail)})` : ''}`,
179
+ `Method: ${sanitizeDisplay(info.methodId)}`,
180
+ `HTTP verb: ${sanitizeDisplay(info.httpMethod)}`,
181
+ `Path params: ${sanitizeDisplay(JSON.stringify(info.pathParams ?? {}))}`,
182
+ `Query params: ${sanitizeDisplay(JSON.stringify(info.queryParams ?? {}))}`,
183
+ `Destinations: ${summary.destinationsText}`,
184
+ `Body: ${summary.bodyText}`,
185
+ 'Accept to run it as shown; decline or cancel to refuse.',
186
+ ].join('\n');
187
+ }
188
+ const UNSUMMARIZABLE_HINT = 'Refused rather than ask with a confirmation that might not show the real destination. Use a curated tool for this operation instead, or restructure the call so its body is plain JSON (no undecodable raw content).';
189
+ /**
190
+ * Gate for every escape-hatch write; returns null when the call may
191
+ * proceed (to executeApiMethod), or a ToolResult when it must not (a
192
+ * hard refusal, the body could not be summarized safely, or the human
193
+ * declined/cancelled/could not be asked).
194
+ */
195
+ export async function escapeHatchGate(registry, denyNames, primaryName, base, policy, info) {
196
+ const outboundActive = resolveOutboundAllowlist() !== null;
197
+ const verdict = escapeHatchVerdict(denyNames, base, primaryName, policy, outboundActive);
198
+ if (verdict.decision === 'allow')
199
+ return null;
200
+ if (verdict.decision === 'deny')
201
+ return writeDisabledResult(verdict.ref, policy, info.account);
202
+ const summary = buildSummary(info);
203
+ if (!summary.ok) {
204
+ return jsonResult({ error: 'write_unsummarizable', message: `This write cannot be safely summarized for confirmation: ${summary.reason}.`, hint: UNSUMMARIZABLE_HINT, retriable: false, account: info.account }, true);
205
+ }
206
+ const outcome = await registry.elicitConfirmation(buildPrompt(info, summary));
207
+ if (outcome.action === 'accept')
208
+ return null;
209
+ if (outcome.action === 'unsupported') {
210
+ // Owner decision: over HTTP this covers BOTH a self-hosted HTTP
211
+ // deployment and an EE hosted tenant, which this server cannot tell
212
+ // apart, so the hint must not instruct the caller to set
213
+ // GOOGLE_PROFILE (a hosted tenant cannot). stdio's causes ARE the
214
+ // operator, so naming the env var there is safe, actionable advice.
215
+ if (outcome.cause === 'http_transport') {
216
+ return jsonResult({
217
+ error: 'elicitation_unsupported',
218
+ message: 'This write needs a human confirmation that this server cannot show yet over this connection.',
219
+ hint: 'Use a curated tool for this operation instead.',
220
+ retriable: false,
221
+ account: info.account,
222
+ }, true);
223
+ }
224
+ return jsonResult({
225
+ error: 'elicitation_unsupported',
226
+ message: `This write needs human confirmation, but the connected client could not be asked (${outcome.reason}).`,
227
+ hint: 'Either set GOOGLE_PROFILE=full-writes (with GOOGLE_OUTBOUND_ALLOWLIST unset) to allow escape-hatch writes without asking, or use a curated tool for this operation instead.',
228
+ retriable: false,
229
+ account: info.account,
230
+ }, true);
231
+ }
232
+ return jsonResult({
233
+ error: 'write_declined',
234
+ message: `The human ${outcome.action === 'decline' ? 'declined' : 'cancelled'} this write via the Google API escape hatch.`,
235
+ hint: 'Ask again if this should go through, or use GOOGLE_PROFILE=full-writes (with GOOGLE_OUTBOUND_ALLOWLIST unset) if escape-hatch writes should not need confirmation at all.',
236
+ retriable: false,
237
+ account: info.account,
238
+ }, true);
239
+ }
@@ -8,5 +8,98 @@ export declare function hostFilesRefused(account: string | undefined, what: stri
8
8
  }[];
9
9
  isError: true;
10
10
  };
11
- export declare function prepareLocalDest(savePath: string, filename: string): string;
11
+ /** Thrown by assertLocalPathAllowed. `reason` lets each tool render its own
12
+ * wording (Gmail's E_ATTACHMENT_FORBIDDEN vs Drive's generic envelope)
13
+ * without re-deriving the policy that produced it. */
14
+ export declare class LocalFileError extends Error {
15
+ readonly reason: 'forbidden' | 'exists';
16
+ constructor(reason: 'forbidden' | 'exists', message: string);
17
+ }
18
+ /** Standard envelope for a LocalFileError, shared so Drive and Gmail's
19
+ * host-file tools render the same shape instead of each inventing one. */
20
+ export declare function localFileErrorResult(error: LocalFileError, account: string | undefined): {
21
+ content: {
22
+ type: "text";
23
+ text: string;
24
+ }[];
25
+ isError: true;
26
+ };
27
+ /** GOOGLE_LOCAL_FILES_DIR, or its default, resolved but not yet checked for
28
+ * existence. `homedir` is a test-only override (see ensureLocalFilesRoot). */
29
+ export declare function localFilesRoot(env?: NodeJS.ProcessEnv, homedir?: () => string): string;
30
+ /** Logs the configured (or default) root once per process, WITHOUT
31
+ * creating it. Safe to call eagerly at startup (and from `config check`):
32
+ * the root itself is created lazily, only on the first actual host-file
33
+ * use (see ensureLocalFilesRoot), so a read-only or missing HOME at boot
34
+ * (hardened systemd, an isolated service user) can never fail server
35
+ * startup over a feature that might never be used this run. */
36
+ export declare function logLocalFilesRootAtStartup(env?: NodeJS.ProcessEnv, homedir?: () => string): void;
37
+ /** Resolves GOOGLE_LOCAL_FILES_DIR (or its default) and creates it (mode
38
+ * 0700, owner-only) if missing. Called lazily, from assertLocalPathAllowed
39
+ * only, on the first actual host-file use, never at startup: mkdir can
40
+ * fail (a read-only or missing HOME, the root already existing as a plain
41
+ * file), and that failure must surface as an ordinary refused tool call,
42
+ * not an uncaught throw out of request handling. */
43
+ export declare function ensureLocalFilesRoot(env?: NodeJS.ProcessEnv, homedir?: () => string): string;
44
+ /**
45
+ * One policy for every host-file tool (Drive upload/update/download/export,
46
+ * Gmail attachments/downloads). A path must resolve, after symlinks, INSIDE
47
+ * GOOGLE_LOCAL_FILES_DIR (or its default, ~/Downloads/mcp-google-multi): a
48
+ * relative path (or filename) resolves inside that root, an absolute one
49
+ * must already be inside it. On top of the root, as defense in depth for a
50
+ * root configured too wide (e.g. $HOME), the path must also not resolve
51
+ * inside the server's own config/token directory (master.key, tokens/*.enc,
52
+ * mcp-tokens.enc), the Discovery cache dir, or /proc (which includes
53
+ * /proc/self/environ: the process's own environment, holding the same
54
+ * secrets a .env file does), nor BE one of the .env files this process
55
+ * actually loaded a secret from, wherever they live. `kind: 'read'`
56
+ * requires the path to exist (realpath resolves it directly, so a missing
57
+ * file's ENOENT surfaces unchanged to the caller); `kind: 'write'` only
58
+ * needs some leading portion to exist yet, so it resolves the deepest
59
+ * existing ancestor instead (see realpathDeepestExisting).
60
+ *
61
+ * Returns the resolved real path: callers should perform the actual I/O
62
+ * against IT, not the caller-supplied one, so a symlink swapped in after
63
+ * this check cannot redirect the operation.
64
+ */
65
+ export declare function assertLocalPathAllowed(targetPath: string, kind: 'read' | 'write'): Promise<string>;
66
+ /** True when GOOGLE_LOCAL_FILES is set to an off-synonym (off|0|false|no,
67
+ * any case, the same switch parsing as GOOGLE_USAGE_METRICS), the operator
68
+ * switch that removes every host-file tool/param regardless of context
69
+ * (see compose.ts). Unset (or empty) leaves host files at their default
70
+ * (enabled). An unrecognised non-empty value fails CLOSED (treated as
71
+ * off), with a warning: a typo in a security-relevant switch must narrow
72
+ * the surface, not silently widen it the way GOOGLE_USAGE_METRICS's own
73
+ * fail-open-to-its-default is fine to for a telemetry switch. */
74
+ export declare function localFilesDisabledByEnv(env?: NodeJS.ProcessEnv): boolean;
75
+ /** Resolve and validate a download/export destination directory, then
76
+ * create it if missing. Refuses to overwrite an existing file: downloads
77
+ * are attacker-influenced content (a shared Drive file, an inbound
78
+ * attachment), so clobbering whatever is already at that path silently is
79
+ * never the right default. */
80
+ export declare function prepareLocalDest(savePath: string, filename: string): Promise<string>;
81
+ /**
82
+ * Cleans up after a failed exclusive (wx) write to destPath, but only when
83
+ * this call's own write actually started. EEXIST means the exclusive open
84
+ * was refused outright: this call lost a TOCTOU race (the existence check
85
+ * above and the real open are not atomic together) to some other call
86
+ * that already owns destPath, so there is nothing here that belongs to
87
+ * this call to delete. Any other error means this call's own fd opened
88
+ * and then failed partway, so the partial file it made is cleaned up as
89
+ * before (best-effort; the original error still wins over a failed unlink).
90
+ */
91
+ export declare function cleanupPartialWrite(destPath: string, err: unknown): Promise<void>;
92
+ /**
93
+ * Reads a path the SERVER itself created and fully controls (e.g.
94
+ * drive_transfer's download+upload fallback staging a file under
95
+ * os.tmpdir() before re-uploading it): this is not a caller-supplied
96
+ * argument, so it must not go through assertLocalPathAllowed. The
97
+ * GOOGLE_LOCAL_FILES_DIR root is an allow-list for host-file TOOL
98
+ * arguments (localPath/savePath/attachment paths); the server's own
99
+ * internal plumbing is not one of those, and must not even have the side
100
+ * effect of creating that root (it may not exist, GOOGLE_LOCAL_FILES may
101
+ * be off, or the caller may not be the owner; none of that should matter
102
+ * for a file the server made for itself and will delete when done).
103
+ */
104
+ export declare function openInternalReadStream(absolutePath: string): Promise<fs.ReadStream>;
12
105
  export declare function openLocalReadStream(localPath: string): Promise<fs.ReadStream>;
@@ -1,6 +1,11 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import * as os from 'os';
3
4
  import { stringifyEnvelope } from './_errors.js';
5
+ import { configDir } from '../config-file.js';
6
+ import { getTokenDir } from '../accounts.js';
7
+ import { discoveryCacheDir } from '../discovery-client.js';
8
+ import { getLoadedEnvFiles } from '../env-load.js';
4
9
  /** Refusal for a host-file argument on a context that may not touch the
5
10
  * server's disk: the host belongs to its operator, not to every caller. */
6
11
  export function hostFilesRefused(account, what) {
@@ -18,26 +23,262 @@ export function hostFilesRefused(account, what) {
18
23
  isError: true,
19
24
  };
20
25
  }
21
- // path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
22
- export function prepareLocalDest(savePath, filename) {
23
- const name = path.basename(filename);
26
+ /** Thrown by assertLocalPathAllowed. `reason` lets each tool render its own
27
+ * wording (Gmail's E_ATTACHMENT_FORBIDDEN vs Drive's generic envelope)
28
+ * without re-deriving the policy that produced it. */
29
+ export class LocalFileError extends Error {
30
+ reason;
31
+ constructor(reason, message) {
32
+ super(message);
33
+ this.reason = reason;
34
+ this.name = 'LocalFileError';
35
+ }
36
+ }
37
+ /** Standard envelope for a LocalFileError, shared so Drive and Gmail's
38
+ * host-file tools render the same shape instead of each inventing one. */
39
+ export function localFileErrorResult(error, account) {
40
+ const code = error.reason === 'exists' ? 'local_path_exists' : 'local_path_forbidden';
41
+ return {
42
+ content: [{
43
+ type: 'text',
44
+ text: stringifyEnvelope({
45
+ error: code,
46
+ message: error.message,
47
+ retriable: false,
48
+ account,
49
+ }),
50
+ }],
51
+ isError: true,
52
+ };
53
+ }
54
+ // path.basename() is a traversal guard: a caller-supplied filename must never escape savePath.
55
+ function sanitizedName(filename) {
56
+ return path.basename(filename);
57
+ }
58
+ // Case-insensitive on win32 (drive-letter/casing differences otherwise make
59
+ // path.relative report "outside" for the same directory).
60
+ function isInside(dir, target) {
61
+ let d = path.resolve(dir);
62
+ let t = path.resolve(target);
63
+ if (process.platform === 'win32') {
64
+ d = d.toLowerCase();
65
+ t = t.toLowerCase();
66
+ }
67
+ const rel = path.relative(d, t);
68
+ if (rel === '')
69
+ return true;
70
+ if (path.isAbsolute(rel))
71
+ return false;
72
+ // A real escape is exactly ".." (target is dir's parent) or starts with
73
+ // "../" (target is outside, via an ancestor); a leaf merely NAMED with
74
+ // leading dots (e.g. "..notes.txt", directly inside `dir`) is not an
75
+ // escape, even though the string also starts with the two characters "..".
76
+ return rel !== '..' && !rel.startsWith(`..${path.sep}`);
77
+ }
78
+ /** Deny if realTarget resolves inside `dir` (both realpath'd so a symlinked
79
+ * dir or 8.3/extended-prefix form can't slip past). */
80
+ async function isInsideRealDir(dir, realTarget) {
81
+ let realDir;
82
+ try {
83
+ realDir = await fs.promises.realpath(dir);
84
+ }
85
+ catch {
86
+ return false; // dir does not exist → target cannot be inside it
87
+ }
88
+ return isInside(realDir, realTarget);
89
+ }
90
+ /** Deny if realTarget IS one of `files` (both realpath'd: a symlink to a
91
+ * loaded .env file is the same secret source as the file itself). */
92
+ async function isOneOfRealFiles(files, realTarget) {
93
+ for (const f of files) {
94
+ try {
95
+ if ((await fs.promises.realpath(f)) === realTarget)
96
+ return true;
97
+ }
98
+ catch {
99
+ // the .env file this process loaded earlier no longer exists: nothing to match
100
+ }
101
+ }
102
+ return false;
103
+ }
104
+ /** realpath of the deepest EXISTING ancestor of `targetPath`, with the
105
+ * still-nonexistent tail (if any) reattached literally. A write target's
106
+ * leaf file, and sometimes several of its parent directories, do not exist
107
+ * yet, so plain realpath would just throw ENOENT; this resolves every
108
+ * component that DOES exist, so a symlinked ancestor directory cannot
109
+ * smuggle the eventual write past the confinement check below. */
110
+ async function realpathDeepestExisting(targetPath) {
111
+ let current = targetPath;
112
+ const tail = [];
113
+ for (;;) {
114
+ try {
115
+ const real = await fs.promises.realpath(current);
116
+ return tail.length > 0 ? path.join(real, ...tail.reverse()) : real;
117
+ }
118
+ catch (err) {
119
+ if (err.code !== 'ENOENT')
120
+ throw err;
121
+ const parent = path.dirname(current);
122
+ if (parent === current)
123
+ throw err; // reached the root and it's still missing
124
+ tail.push(path.basename(current));
125
+ current = parent;
126
+ }
127
+ }
128
+ }
129
+ /** GOOGLE_LOCAL_FILES_DIR, or its default, resolved but not yet checked for
130
+ * existence. `homedir` is a test-only override (see ensureLocalFilesRoot). */
131
+ export function localFilesRoot(env = process.env, homedir = os.homedir) {
132
+ const raw = env.GOOGLE_LOCAL_FILES_DIR;
133
+ return raw && raw.trim() !== '' ? path.resolve(raw.trim()) : path.join(homedir(), 'Downloads', 'mcp-google-multi');
134
+ }
135
+ let loggedRoot;
136
+ /** Logs the configured (or default) root once per process, WITHOUT
137
+ * creating it. Safe to call eagerly at startup (and from `config check`):
138
+ * the root itself is created lazily, only on the first actual host-file
139
+ * use (see ensureLocalFilesRoot), so a read-only or missing HOME at boot
140
+ * (hardened systemd, an isolated service user) can never fail server
141
+ * startup over a feature that might never be used this run. */
142
+ export function logLocalFilesRootAtStartup(env = process.env, homedir = os.homedir) {
143
+ try {
144
+ const root = localFilesRoot(env, homedir);
145
+ if (loggedRoot !== root) {
146
+ loggedRoot = root;
147
+ process.stderr.write(`Host-file tools (uploads, downloads, attachments), when used, are confined to: ${root}\n`);
148
+ }
149
+ }
150
+ catch {
151
+ // Best-effort only: never let a log line fail startup.
152
+ }
153
+ }
154
+ /** Resolves GOOGLE_LOCAL_FILES_DIR (or its default) and creates it (mode
155
+ * 0700, owner-only) if missing. Called lazily, from assertLocalPathAllowed
156
+ * only, on the first actual host-file use, never at startup: mkdir can
157
+ * fail (a read-only or missing HOME, the root already existing as a plain
158
+ * file), and that failure must surface as an ordinary refused tool call,
159
+ * not an uncaught throw out of request handling. */
160
+ export function ensureLocalFilesRoot(env = process.env, homedir = os.homedir) {
161
+ const root = localFilesRoot(env, homedir);
162
+ try {
163
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
164
+ }
165
+ catch (err) {
166
+ throw new LocalFileError('forbidden', `could not create or access the local-files root (${root}): ${err.message}`);
167
+ }
168
+ if (loggedRoot !== root) {
169
+ loggedRoot = root;
170
+ process.stderr.write(`Host-file tools (uploads, downloads, attachments) are confined to: ${root}\n`);
171
+ }
172
+ return root;
173
+ }
174
+ /**
175
+ * One policy for every host-file tool (Drive upload/update/download/export,
176
+ * Gmail attachments/downloads). A path must resolve, after symlinks, INSIDE
177
+ * GOOGLE_LOCAL_FILES_DIR (or its default, ~/Downloads/mcp-google-multi): a
178
+ * relative path (or filename) resolves inside that root, an absolute one
179
+ * must already be inside it. On top of the root, as defense in depth for a
180
+ * root configured too wide (e.g. $HOME), the path must also not resolve
181
+ * inside the server's own config/token directory (master.key, tokens/*.enc,
182
+ * mcp-tokens.enc), the Discovery cache dir, or /proc (which includes
183
+ * /proc/self/environ: the process's own environment, holding the same
184
+ * secrets a .env file does), nor BE one of the .env files this process
185
+ * actually loaded a secret from, wherever they live. `kind: 'read'`
186
+ * requires the path to exist (realpath resolves it directly, so a missing
187
+ * file's ENOENT surfaces unchanged to the caller); `kind: 'write'` only
188
+ * needs some leading portion to exist yet, so it resolves the deepest
189
+ * existing ancestor instead (see realpathDeepestExisting).
190
+ *
191
+ * Returns the resolved real path: callers should perform the actual I/O
192
+ * against IT, not the caller-supplied one, so a symlink swapped in after
193
+ * this check cannot redirect the operation.
194
+ */
195
+ export async function assertLocalPathAllowed(targetPath, kind) {
196
+ const root = ensureLocalFilesRoot();
197
+ const absolute = path.isAbsolute(targetPath) ? targetPath : path.join(root, targetPath);
198
+ const realPath = kind === 'read' ? await fs.promises.realpath(absolute) : await realpathDeepestExisting(absolute);
199
+ if (!(await isInsideRealDir(root, realPath))) {
200
+ throw new LocalFileError('forbidden', `refusing to ${kind} a path outside the allowed local-files root (${root}): ${path.basename(targetPath)}`);
201
+ }
202
+ const deniedDirs = [configDir(), getTokenDir(), discoveryCacheDir(), '/proc'];
203
+ for (const dir of deniedDirs) {
204
+ if (await isInsideRealDir(dir, realPath)) {
205
+ throw new LocalFileError('forbidden', `refusing to ${kind} a path inside the server's config/token/cache directory, or /proc: ${path.basename(targetPath)}`);
206
+ }
207
+ }
208
+ if (await isOneOfRealFiles(getLoadedEnvFiles(), realPath)) {
209
+ throw new LocalFileError('forbidden', `refusing to ${kind} a .env file this server loaded secrets from: ${path.basename(targetPath)}`);
210
+ }
211
+ return realPath;
212
+ }
213
+ const LOCAL_FILES_OFF = new Set(['off', '0', 'false', 'no']);
214
+ const LOCAL_FILES_ON = new Set(['on', '1', 'true', 'yes']);
215
+ /** True when GOOGLE_LOCAL_FILES is set to an off-synonym (off|0|false|no,
216
+ * any case, the same switch parsing as GOOGLE_USAGE_METRICS), the operator
217
+ * switch that removes every host-file tool/param regardless of context
218
+ * (see compose.ts). Unset (or empty) leaves host files at their default
219
+ * (enabled). An unrecognised non-empty value fails CLOSED (treated as
220
+ * off), with a warning: a typo in a security-relevant switch must narrow
221
+ * the surface, not silently widen it the way GOOGLE_USAGE_METRICS's own
222
+ * fail-open-to-its-default is fine to for a telemetry switch. */
223
+ export function localFilesDisabledByEnv(env = process.env) {
224
+ const raw = env.GOOGLE_LOCAL_FILES;
225
+ if (raw === undefined || raw.trim() === '')
226
+ return false;
227
+ const v = raw.trim().toLowerCase();
228
+ if (LOCAL_FILES_OFF.has(v))
229
+ return true;
230
+ if (LOCAL_FILES_ON.has(v))
231
+ return false;
232
+ process.stderr.write(`GOOGLE_LOCAL_FILES="${raw}" is not a valid value (off|0|false|no / on|1|true|yes); host-file tools are disabled (fail closed)\n`);
233
+ return true;
234
+ }
235
+ /** Resolve and validate a download/export destination directory, then
236
+ * create it if missing. Refuses to overwrite an existing file: downloads
237
+ * are attacker-influenced content (a shared Drive file, an inbound
238
+ * attachment), so clobbering whatever is already at that path silently is
239
+ * never the right default. */
240
+ export async function prepareLocalDest(savePath, filename) {
241
+ const name = sanitizedName(filename);
24
242
  // Agents routinely repeat the leaf in both savePath and filename; a blind
25
243
  // join would bury the download in a directory named like the file itself.
26
244
  const dir = path.basename(savePath) === name ? path.dirname(savePath) : savePath;
27
- const dest = path.join(dir, name);
28
- fs.mkdirSync(dir, { recursive: true });
245
+ // Write through the SAME resolved real path that was just checked, not
246
+ // the caller-supplied one: re-deriving the destination from `dir` again
247
+ // here would let a symlink swapped in between the check and this mkdir
248
+ // (TOCTOU) redirect the eventual write past the check that just ran.
249
+ const realDir = await assertLocalPathAllowed(dir, 'write');
250
+ fs.mkdirSync(realDir, { recursive: true });
251
+ const dest = path.join(realDir, name);
252
+ if (fs.existsSync(dest)) {
253
+ throw new LocalFileError('exists', `refusing to overwrite an existing file: ${dest}`);
254
+ }
29
255
  return dest;
30
256
  }
257
+ /**
258
+ * Cleans up after a failed exclusive (wx) write to destPath, but only when
259
+ * this call's own write actually started. EEXIST means the exclusive open
260
+ * was refused outright: this call lost a TOCTOU race (the existence check
261
+ * above and the real open are not atomic together) to some other call
262
+ * that already owns destPath, so there is nothing here that belongs to
263
+ * this call to delete. Any other error means this call's own fd opened
264
+ * and then failed partway, so the partial file it made is cleaned up as
265
+ * before (best-effort; the original error still wins over a failed unlink).
266
+ */
267
+ export async function cleanupPartialWrite(destPath, err) {
268
+ if (err?.code === 'EEXIST')
269
+ return;
270
+ await fs.promises.unlink(destPath).catch(() => { });
271
+ }
31
272
  // createReadStream's open failure surfaces as an unhandled 'error' event that
32
273
  // kills the process; opening the fd first turns it into a normal rejection.
33
- export async function openLocalReadStream(localPath) {
34
- const handle = await fs.promises.open(localPath, 'r');
274
+ async function openFdReadStream(absolutePath) {
275
+ const handle = await fs.promises.open(absolutePath, 'r');
35
276
  // open() succeeds on a directory; fail it here rather than as an async read error.
36
277
  if ((await handle.stat()).isDirectory()) {
37
278
  await handle.close();
38
- throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${localPath}'`), {
279
+ throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${absolutePath}'`), {
39
280
  code: 'EISDIR',
40
- path: localPath,
281
+ path: absolutePath,
41
282
  });
42
283
  }
43
284
  const stream = handle.createReadStream();
@@ -46,3 +287,22 @@ export async function openLocalReadStream(localPath) {
46
287
  stream.on('error', () => { });
47
288
  return stream;
48
289
  }
290
+ /**
291
+ * Reads a path the SERVER itself created and fully controls (e.g.
292
+ * drive_transfer's download+upload fallback staging a file under
293
+ * os.tmpdir() before re-uploading it): this is not a caller-supplied
294
+ * argument, so it must not go through assertLocalPathAllowed. The
295
+ * GOOGLE_LOCAL_FILES_DIR root is an allow-list for host-file TOOL
296
+ * arguments (localPath/savePath/attachment paths); the server's own
297
+ * internal plumbing is not one of those, and must not even have the side
298
+ * effect of creating that root (it may not exist, GOOGLE_LOCAL_FILES may
299
+ * be off, or the caller may not be the owner; none of that should matter
300
+ * for a file the server made for itself and will delete when done).
301
+ */
302
+ export async function openInternalReadStream(absolutePath) {
303
+ return openFdReadStream(absolutePath);
304
+ }
305
+ export async function openLocalReadStream(localPath) {
306
+ const realPath = await assertLocalPathAllowed(localPath, 'read');
307
+ return openFdReadStream(realPath);
308
+ }