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.
- package/dist/compose.js +13 -1
- package/dist/env-load.d.ts +6 -0
- package/dist/env-load.js +8 -0
- package/dist/executor.js +4 -6
- package/dist/identity.js +4 -2
- package/dist/index.js +2 -1
- package/dist/outbound-allowlist.d.ts +36 -3
- package/dist/outbound-allowlist.js +80 -44
- package/dist/registry.d.ts +46 -0
- package/dist/registry.js +67 -1
- package/dist/tools/_escape-hatch.d.ts +23 -0
- package/dist/tools/_escape-hatch.js +239 -0
- package/dist/tools/_local-files.d.ts +94 -1
- package/dist/tools/_local-files.js +269 -9
- package/dist/tools/drive.js +80 -13
- package/dist/tools/generated/_shared.js +22 -0
- package/dist/tools/generated/admin.js +2 -2
- package/dist/tools/generated/method-map.d.ts +3 -0
- package/dist/tools/generated/method-map.js +156 -1
- package/dist/tools/gmail.d.ts +6 -0
- package/dist/tools/gmail.js +71 -47
- package/dist/tools/google-api.d.ts +18 -1
- package/dist/tools/google-api.js +86 -12
- package/dist/usage-metrics.js +1 -1
- package/dist/write-control.d.ts +31 -7
- package/dist/write-control.js +30 -2
- package/package.json +1 -1
- package/skills/google-workspace/SKILL.md +21 -9
- package/skills/google-workspace/reference/drive.md +4 -4
- package/skills/google-workspace/reference/gmail.md +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
const handle = await fs.promises.open(
|
|
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 '${
|
|
279
|
+
throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${absolutePath}'`), {
|
|
39
280
|
code: 'EISDIR',
|
|
40
|
-
path:
|
|
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
|
+
}
|