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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +1340 -262
- package/dist/lib.js +1381 -260
- package/manifest.json +9 -2
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/arg-guard.ts +68 -0
- package/src/argv.ts +27 -0
- package/src/attachment-root.ts +111 -0
- package/src/attachments.ts +1 -0
- package/src/blob-upload.ts +4 -2
- package/src/dispatch-confirmation.ts +277 -0
- package/src/file-roots.ts +66 -0
- package/src/gmail-dispatch-guard.ts +70 -30
- package/src/gmail-results.ts +21 -2
- package/src/lib.ts +15 -0
- package/src/run-path-guard.ts +118 -0
- package/src/runner.ts +86 -18
- package/src/send-confirm-token.ts +167 -0
- package/src/tools/api.ts +60 -7
- package/src/tools/appscript.ts +12 -8
- package/src/tools/auth.ts +13 -3
- package/src/tools/calendar.ts +178 -15
- package/src/tools/chat.ts +94 -18
- package/src/tools/classroom.ts +110 -28
- package/src/tools/contacts.ts +5 -3
- package/src/tools/docs.ts +8 -6
- package/src/tools/drive.ts +96 -20
- package/src/tools/gmail.ts +175 -24
- package/src/tools/sheets.ts +10 -8
- package/src/tools/slides.ts +14 -9
- package/src/tools/tasks.ts +7 -5
- package/src/tools/utils.ts +52 -6
- package/tests/arg-guard.test.ts +80 -0
- package/tests/attachment-root.test.ts +130 -0
- package/tests/attachments.test.ts +8 -0
- package/tests/blob-upload.test.ts +4 -3
- package/tests/file-roots.test.ts +101 -0
- package/tests/gmail-dispatch-guard.test.ts +270 -11
- package/tests/gmail-results.test.ts +35 -1
- package/tests/run-path-guard.test.ts +142 -0
- package/tests/runner.test.ts +136 -0
- package/tests/send-confirm-token.test.ts +200 -0
- package/tests/tools/api.test.ts +74 -8
- package/tests/tools/appscript.test.ts +34 -8
- package/tests/tools/auth.test.ts +44 -17
- package/tests/tools/calendar.test.ts +33 -28
- package/tests/tools/chat.test.ts +50 -21
- package/tests/tools/classroom.test.ts +43 -39
- package/tests/tools/contacts.test.ts +3 -2
- package/tests/tools/dispatch-gates.test.ts +396 -0
- package/tests/tools/docs.test.ts +44 -15
- package/tests/tools/drive.test.ts +110 -20
- package/tests/tools/gmail-confirm-token.test.ts +274 -0
- package/tests/tools/gmail.test.ts +227 -29
- package/tests/tools/run-tool-examples.test.ts +69 -0
- package/tests/tools/run-vets.test.ts +131 -0
- package/tests/tools/sheets.test.ts +16 -15
- package/tests/tools/slides.test.ts +47 -11
- package/tests/tools/tasks.test.ts +7 -6
- package/tests/tools/utils.test.ts +32 -31
- package/vitest.config.ts +5 -0
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
|
|
2
|
-
import { readEnvVar
|
|
2
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
3
|
+
import { CONFIRM_SEND_INSTRUCTION, requireDispatchConfirmation, type DispatchTokenFallback } from './dispatch-confirmation.js';
|
|
4
|
+
|
|
5
|
+
// The generic pieces moved to dispatch-confirmation.ts when Chat, Calendar,
|
|
6
|
+
// Drive and Classroom joined the rail; re-exported so Gmail callers keep one import.
|
|
7
|
+
export {
|
|
8
|
+
attachmentDetails,
|
|
9
|
+
attachmentNames,
|
|
10
|
+
BODY_PREVIEW_MAX,
|
|
11
|
+
bodyPreview,
|
|
12
|
+
attachmentPreview,
|
|
13
|
+
CONFIRM_FALLBACK_DESCRIPTION,
|
|
14
|
+
CONFIRM_SEND_INSTRUCTION as CONFIRM_INSTRUCTION,
|
|
15
|
+
confirmTokenParam,
|
|
16
|
+
resultText,
|
|
17
|
+
senderPreview,
|
|
18
|
+
} from './dispatch-confirmation.js';
|
|
19
|
+
export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './dispatch-confirmation.js';
|
|
3
20
|
|
|
4
21
|
// ============================================================================
|
|
5
|
-
// THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply
|
|
6
|
-
// the
|
|
7
|
-
// else's mailbox
|
|
22
|
+
// THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply /
|
|
23
|
+
// drafts_send, and a filter that forwards, are the tools in this fleet that put
|
|
24
|
+
// a message irreversibly into someone else's mailbox. Every other Gmail write either stages
|
|
8
25
|
// something (drafts) or acts on mail already in this account (labels,
|
|
9
26
|
// archive, trash). A caller that meant "save a draft" and picked the wrong
|
|
10
27
|
// tool — or an agent that inherited the wrong reply target — used to find out
|
|
@@ -15,6 +32,12 @@ import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
|
|
|
15
32
|
// user's accepted confirmation dispatches. The confirmation is never a tool
|
|
16
33
|
// argument, so a model cannot bypass the user by setting a boolean itself.
|
|
17
34
|
//
|
|
35
|
+
// The one exception is OPT-IN: with GOG_SEND_CONFIRM_FALLBACK=token, a client
|
|
36
|
+
// that cannot be prompted gets a two-phase preview + confirmToken instead of a
|
|
37
|
+
// refusal (`DispatchTokenFallback`, send-confirm-token.ts). There the approval
|
|
38
|
+
// IS a tool argument; see that file for exactly what the token does and does
|
|
39
|
+
// not guarantee.
|
|
40
|
+
//
|
|
18
41
|
// A CLIENT THAT CANNOT SHOW THAT PROMPT gets a sentence rather than a prompt
|
|
19
42
|
// it will refuse to deliver (`unsupportedNote`, mcp-utils `requireConfirmation`).
|
|
20
43
|
// Measured on the mcp-host fleet 2026-09-20: claude.ai declares no MCP
|
|
@@ -26,7 +49,7 @@ import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
|
|
|
26
49
|
// ============================================================================
|
|
27
50
|
|
|
28
51
|
/**
|
|
29
|
-
* The
|
|
52
|
+
* The dispatches this rail guards, spelled once.
|
|
30
53
|
*
|
|
31
54
|
* A UNION rather than `string`, because the staging-twin table below is keyed
|
|
32
55
|
* by these values and a key that matches no call site is silent: it costs the
|
|
@@ -41,7 +64,14 @@ export type GmailDispatchOp =
|
|
|
41
64
|
| 'gmail.reply'
|
|
42
65
|
| 'gmail.reply-all'
|
|
43
66
|
| 'gmail.forward'
|
|
44
|
-
| 'gmail.autoreply'
|
|
67
|
+
| 'gmail.autoreply'
|
|
68
|
+
// Sending a staged draft dispatches mail just as irreversibly as a direct
|
|
69
|
+
// send; draft-create then drafts-send was an unconfirmed two-step around the
|
|
70
|
+
// rail (audit SEC-2).
|
|
71
|
+
| 'gmail.drafts-send'
|
|
72
|
+
// A filter with a forward action sends every FUTURE matching message to
|
|
73
|
+
// another address — persistent exfiltration, not a one-off send.
|
|
74
|
+
| 'gmail.filter-forward';
|
|
45
75
|
|
|
46
76
|
/** Every op, for tests that must cover the set rather than a chosen member. */
|
|
47
77
|
export const GMAIL_DISPATCH_OPS: readonly GmailDispatchOp[] = [
|
|
@@ -50,6 +80,8 @@ export const GMAIL_DISPATCH_OPS: readonly GmailDispatchOp[] = [
|
|
|
50
80
|
'gmail.reply-all',
|
|
51
81
|
'gmail.forward',
|
|
52
82
|
'gmail.autoreply',
|
|
83
|
+
'gmail.drafts-send',
|
|
84
|
+
'gmail.filter-forward',
|
|
53
85
|
];
|
|
54
86
|
|
|
55
87
|
/**
|
|
@@ -66,9 +98,9 @@ export function replyDispatchOp(kind: 'reply' | 'reply-all'): GmailDispatchOp {
|
|
|
66
98
|
|
|
67
99
|
/**
|
|
68
100
|
* The staging twin of each dispatch, named in the refusal above. Every one of
|
|
69
|
-
* these saves without sending
|
|
70
|
-
*
|
|
71
|
-
*
|
|
101
|
+
* these saves without sending. `gog_gmail_drafts_send` asks for confirmation
|
|
102
|
+
* too, so on a client that cannot show a prompt the way through is the USER
|
|
103
|
+
* sending the saved draft from Gmail — a human in the loop either way.
|
|
72
104
|
*
|
|
73
105
|
* `Partial<Record<…>>` and not an index signature: a key outside the union is
|
|
74
106
|
* now rejected by the compiler, which is the whole point, while `autoreply`
|
|
@@ -83,24 +115,41 @@ const STAGING_TWIN: Partial<Record<GmailDispatchOp, string>> = {
|
|
|
83
115
|
'gmail.send': 'gog_gmail_drafts_create',
|
|
84
116
|
};
|
|
85
117
|
|
|
86
|
-
|
|
87
|
-
|
|
118
|
+
// What to say when there is no staging twin but there IS still a way through.
|
|
119
|
+
const UNSUPPORTED_NOTE: Partial<Record<GmailDispatchOp, string>> = {
|
|
120
|
+
'gmail.drafts-send': 'The draft is still saved: ask the user to review it and send it from Gmail.',
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply the shared stateless confirmation flow with Gmail-specific copy.
|
|
125
|
+
*
|
|
126
|
+
* Elicitation stays the primary path and is untouched. Only when the caller
|
|
127
|
+
* declares it cannot be prompted AND a `fallback` is supplied AND
|
|
128
|
+
* GOG_SEND_CONFIRM_FALLBACK=token does the two-phase token flow run instead of
|
|
129
|
+
* the refusal; with the env unset, the refusal names that switch.
|
|
130
|
+
*/
|
|
131
|
+
export async function requireGmailDispatchConfirmation(
|
|
88
132
|
ctx: ServerContext,
|
|
89
133
|
op: GmailDispatchOp,
|
|
90
134
|
details: Record<string, unknown>,
|
|
91
|
-
|
|
135
|
+
fallback?: DispatchTokenFallback,
|
|
136
|
+
): Promise<InputRequiredResult | CallToolResult | undefined> {
|
|
92
137
|
const twin = STAGING_TWIN[op];
|
|
93
|
-
|
|
138
|
+
const note = twin
|
|
139
|
+
? `Stage it with ${twin} instead; the user can review the draft and send it from Gmail `
|
|
140
|
+
+ '(gog_gmail_drafts_send also asks for confirmation).'
|
|
141
|
+
: UNSUPPORTED_NOTE[op];
|
|
142
|
+
return requireDispatchConfirmation(ctx, {
|
|
94
143
|
action: op,
|
|
95
|
-
message:
|
|
144
|
+
message: op === 'gmail.filter-forward'
|
|
145
|
+
? 'Review and confirm this mail-forwarding filter:'
|
|
146
|
+
: 'Review and confirm this email dispatch:',
|
|
96
147
|
details,
|
|
97
|
-
confirmationLabel:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
: {}),
|
|
148
|
+
confirmationLabel: op === 'gmail.filter-forward'
|
|
149
|
+
? 'Confirm that matching mail should be forwarded automatically from now on.'
|
|
150
|
+
: 'Confirm that this email should be sent now.',
|
|
151
|
+
unsupportedNote: note,
|
|
152
|
+
...(fallback ? { fallback: { instruction: CONFIRM_SEND_INSTRUCTION, ...fallback } } : {}),
|
|
104
153
|
});
|
|
105
154
|
}
|
|
106
155
|
|
|
@@ -170,12 +219,3 @@ export function logGmailDispatch(tool: string, recipients: string[], account?: s
|
|
|
170
219
|
};
|
|
171
220
|
process.stderr.write(`${JSON.stringify(event)}\n`);
|
|
172
221
|
}
|
|
173
|
-
|
|
174
|
-
// The single place a CallToolResult's text is pulled back out, for the tools
|
|
175
|
-
// here that need to read gog's own JSON before deciding what to preview or
|
|
176
|
-
// log. Mirrors the shape every runOrDiagnose result actually returns
|
|
177
|
-
// (content[0].text); never throws on an unexpected shape.
|
|
178
|
-
export function resultText(result: CallToolResult): string {
|
|
179
|
-
const first = result.content[0];
|
|
180
|
-
return first && first.type === 'text' && typeof first.text === 'string' ? first.text : '{}';
|
|
181
|
-
}
|
package/src/gmail-results.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { rawTextResult } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { run } from './runner.js';
|
|
4
4
|
import { annotateTruncation, hasMorePages } from './pagination.js';
|
|
5
5
|
import type { MatchCount } from './pagination.js';
|
|
6
|
+
import { pos } from './argv.js';
|
|
6
7
|
|
|
7
8
|
// Post-processing for Gmail search output, on the seam between gog's JSON and
|
|
8
9
|
// the model client. Two guarantees live here, both of which exist because a
|
|
@@ -102,7 +103,7 @@ async function countMatches(
|
|
|
102
103
|
maxResults: COUNT_PROBE_PAGE_SIZE,
|
|
103
104
|
fields: `${itemsKey}/id,nextPageToken`,
|
|
104
105
|
});
|
|
105
|
-
const raw = await run(['api', 'call', 'gmail', 'v1', method, `--params=${params}`], { account });
|
|
106
|
+
const raw = await run(['api', 'call', 'gmail', 'v1', pos(method), `--params=${params}`], { account });
|
|
106
107
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
107
108
|
const items = parsed[itemsKey];
|
|
108
109
|
if (!Array.isArray(items)) return {};
|
|
@@ -208,8 +209,13 @@ export async function fetchGmailPages(
|
|
|
208
209
|
// Nothing collected yet means the caller should just see that result; once
|
|
209
210
|
// pages ARE collected, return them WITH the cursor that was about to be
|
|
210
211
|
// consumed, so the set still reads as truncated rather than complete.
|
|
212
|
+
// The failing page's own text rides along as `pageError` (audit QUAL-1):
|
|
213
|
+
// without it the caller sees only "more exist", retries the same failing
|
|
214
|
+
// page, and never learns WHY — e.g. that the account needs re-auth.
|
|
211
215
|
if (parsed === undefined) {
|
|
212
|
-
return base === undefined
|
|
216
|
+
return base === undefined
|
|
217
|
+
? result
|
|
218
|
+
: finish(base, itemsKey, merged, token, pageErrorText(result, itemsKey, pages + 1));
|
|
213
219
|
}
|
|
214
220
|
base = parsed;
|
|
215
221
|
merged.push(...(parsed[itemsKey] as unknown[]));
|
|
@@ -253,14 +259,27 @@ function parsePage(
|
|
|
253
259
|
return Array.isArray(obj[itemsKey]) ? obj : undefined;
|
|
254
260
|
}
|
|
255
261
|
|
|
262
|
+
// What went wrong on page `n` of a walk, bounded so a stray HTML page cannot
|
|
263
|
+
// become the payload. An error result is already diagnosed text (hints and
|
|
264
|
+
// all); anything else is output this walk could not read as a list.
|
|
265
|
+
const PAGE_ERROR_MAX = 2000;
|
|
266
|
+
function pageErrorText(result: CallToolResult, itemsKey: string, n: number): string {
|
|
267
|
+
const first = result.content[0];
|
|
268
|
+
const text = first?.type === 'text' ? first.text.slice(0, PAGE_ERROR_MAX) : '';
|
|
269
|
+
if (result.isError) return `page ${n} failed${text ? `: ${text}` : ''}`;
|
|
270
|
+
return `page ${n} returned output that is not a ${itemsKey} list`;
|
|
271
|
+
}
|
|
272
|
+
|
|
256
273
|
function finish(
|
|
257
274
|
base: Record<string, unknown>,
|
|
258
275
|
itemsKey: string,
|
|
259
276
|
merged: unknown[],
|
|
260
277
|
token: string | undefined,
|
|
278
|
+
pageError?: string,
|
|
261
279
|
): CallToolResult {
|
|
262
280
|
const out: Record<string, unknown> = { ...base, [itemsKey]: merged };
|
|
263
281
|
if (token === undefined) delete out.nextPageToken;
|
|
264
282
|
else out.nextPageToken = token;
|
|
283
|
+
if (pageError !== undefined) out.pageError = pageError;
|
|
265
284
|
return rawTextResult(JSON.stringify(out));
|
|
266
285
|
}
|
package/src/lib.ts
CHANGED
|
@@ -26,11 +26,22 @@ export type { ReplyFlags } from './tools/gmail.js';
|
|
|
26
26
|
// gmail sub-package's send-side forward/autoreply tools reuse these directly
|
|
27
27
|
// rather than re-declaring the gate; the draft-side twins never import them.
|
|
28
28
|
export {
|
|
29
|
+
attachmentDetails,
|
|
30
|
+
attachmentNames,
|
|
31
|
+
attachmentPreview,
|
|
32
|
+
bodyPreview,
|
|
33
|
+
CONFIRM_FALLBACK_DESCRIPTION,
|
|
34
|
+
confirmTokenParam,
|
|
29
35
|
extractEmails,
|
|
30
36
|
logGmailDispatch,
|
|
31
37
|
requireGmailDispatchConfirmation,
|
|
32
38
|
resultText,
|
|
39
|
+
senderPreview,
|
|
33
40
|
} from './gmail-dispatch-guard.js';
|
|
41
|
+
// The service-neutral rail for every other tool that reaches another person.
|
|
42
|
+
export { requireDispatchConfirmation } from './dispatch-confirmation.js';
|
|
43
|
+
export { readCourse } from './tools/classroom.js';
|
|
44
|
+
export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './gmail-dispatch-guard.js';
|
|
34
45
|
export { run, runBinary, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
|
|
35
46
|
// Sub-package tools that read gog JSON through bare `run()` (rather than the
|
|
36
47
|
// `runOrDiagnose` seam) must still apply this, or their timestamps skip the
|
|
@@ -45,6 +56,10 @@ export type { FinalizeOptions, GmailListMethod } from './gmail-results.js';
|
|
|
45
56
|
export { bootstrapGogAuth, AUTH_BOOTSTRAP_MARKER } from './bootstrap-auth.js';
|
|
46
57
|
export type { AuthBootstrapStatus, AuthBootstrapOptions } from './bootstrap-auth.js';
|
|
47
58
|
export type { RunOptions, Spawner, GogArg, GogFileArg } from './runner.js';
|
|
59
|
+
export { pos } from './argv.js';
|
|
60
|
+
export { confinePath, confinePaths, confineAtFile, fileRoots, defaultFileRoot, FILE_ROOTS_ENV } from './file-roots.js';
|
|
61
|
+
export { prepareDownloadRoot, removeDownload, ATTACHMENT_TTL_MS } from './attachment-root.js';
|
|
62
|
+
export type { GogPositional } from './argv.js';
|
|
48
63
|
// Caller-supplied attachment bytes — the only outbound attachment path that
|
|
49
64
|
// works when the caller and gog share no filesystem (a hosted deployment such
|
|
50
65
|
// as mcp-host). See src/attachments.ts.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// GOG_FILE_ROOTS for the escape hatches (audit SEC-3/SEC-4).
|
|
2
|
+
//
|
|
3
|
+
// The structured tools confine every server-side path they accept, but a
|
|
4
|
+
// `gog_<service>_run` forwards a model-supplied string[] verbatim, and gog reads
|
|
5
|
+
// and writes local files through it just as readily: `drive upload
|
|
6
|
+
// <gog's credentials.json>`, `gmail drafts create --attach=~/.ssh/id_rsa`,
|
|
7
|
+
// `docs export --out=~/.zshrc`. Left open, that is the read-and-exfiltrate and
|
|
8
|
+
// write-anywhere primitive the roots exist to close, and GOG_FILE_ROOTS would
|
|
9
|
+
// only look like a boundary. So, before an escape hatch runs:
|
|
10
|
+
//
|
|
11
|
+
// - subcommands whose local path is a POSITIONAL are refused outright. Which
|
|
12
|
+
// positional is the path depends on the command, and kong lets flags sit
|
|
13
|
+
// anywhere, so it cannot be picked out reliably; each has a dedicated tool
|
|
14
|
+
// that confines it.
|
|
15
|
+
// - every path-bearing FLAG value (`--out`, `--attach`, `--file`, `--*-file`,
|
|
16
|
+
// `--out-dir`, `--dir`, an `@file` JSON input, ...) must resolve inside the
|
|
17
|
+
// roots, whether it is attached (`--out=x`) or the next token (`--out x`).
|
|
18
|
+
// - the short spellings `-o` / `-f` are refused, since a cluster (`-yf x`,
|
|
19
|
+
// `-o/etc/x`) hides the value; the long form is confined instead.
|
|
20
|
+
// - flags that make gog RUN a local program (`--on-change`, `--on-new`,
|
|
21
|
+
// `--mmdc`) are refused: a model-chosen shell command is a strictly worse
|
|
22
|
+
// primitive than a path.
|
|
23
|
+
//
|
|
24
|
+
// Kept out of runner.ts for the same reason as arg-guard.ts: tool tests
|
|
25
|
+
// automock the runner, and this has to run for real in the tool handlers.
|
|
26
|
+
|
|
27
|
+
import { confinePath } from './file-roots.js';
|
|
28
|
+
|
|
29
|
+
/** Escape-hatch subcommands whose local path is positional, and the tool to use instead. */
|
|
30
|
+
const POSITIONAL_PATH_SUBCOMMANDS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
|
31
|
+
appscript: { pull: 'gog_appscript_pull' },
|
|
32
|
+
drive: { upload: 'gog_drive_upload', sync: 'gog_drive_sync_push' },
|
|
33
|
+
gmail: { import: 'gog_gmail_import' },
|
|
34
|
+
slides: {
|
|
35
|
+
'add-slide': 'gog_slides_add_slide',
|
|
36
|
+
'insert-image': 'gog_slides_insert_image',
|
|
37
|
+
'replace-slide': 'gog_slides_replace_slide',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Long flags (lower-case, no dashes) whose value is a local path (from `gog schema`, v0.41.0). */
|
|
42
|
+
const PATH_FLAGS = new Set(['out', 'output', 'out-dir', 'output-dir', 'dir', 'worker-dir', 'attach', 'file', 'key', 'cert', 'replacements']);
|
|
43
|
+
|
|
44
|
+
/** Per service: flags that look like paths but carry Drive file IDs. */
|
|
45
|
+
const ID_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
46
|
+
drive: new Set(['file', 'filter-file']),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Flags whose value gog executes as a local command or program. */
|
|
50
|
+
const EXEC_FLAGS = new Set(['on-change', 'on-new', 'mmdc']);
|
|
51
|
+
|
|
52
|
+
// Single-dash clusters containing -o (--out) or -f (--file).
|
|
53
|
+
const SHORT_PATH_CLUSTER = /^-(?!-)[A-Za-z]*[of]/;
|
|
54
|
+
|
|
55
|
+
const LONG_FLAG = /^--([A-Za-z0-9][A-Za-z0-9-]*)(?:=([\s\S]*))?$/;
|
|
56
|
+
|
|
57
|
+
function isPathFlag(service: string, name: string): boolean {
|
|
58
|
+
if (ID_FLAGS[service]?.has(name)) return false;
|
|
59
|
+
return PATH_FLAGS.has(name) || name.endsWith('-file');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function confineFlagValue(flag: string, value: string): void {
|
|
63
|
+
// `-` is stdin/stdout, which never names a file on the host.
|
|
64
|
+
if (value === '-') return;
|
|
65
|
+
confinePath(value, flag);
|
|
66
|
+
// kong splits a repeatable ([]string) flag on commas, so `--attach=a,b` is
|
|
67
|
+
// two paths: each must be inside the roots too.
|
|
68
|
+
for (const part of value.split(',')) {
|
|
69
|
+
if (part !== '' && part !== '-') confinePath(part, flag);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The dedicated tool to use when `gog <service> <subcommand>` is refused for a positional path, else undefined. */
|
|
74
|
+
export function positionalPathTool(service: string, subcommand: string): string | undefined {
|
|
75
|
+
return POSITIONAL_PATH_SUBCOMMANDS[service]?.[subcommand];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Throw unless every local path in an escape-hatch call (`gog <service>
|
|
80
|
+
* <subcommand> ...args`) lies inside GOG_FILE_ROOTS (or the private attachment
|
|
81
|
+
* download root), and nothing in it would run a local program.
|
|
82
|
+
*/
|
|
83
|
+
export function assertRunPathsConfined(service: string, subcommand: string, args: readonly string[]): void {
|
|
84
|
+
const dedicated = positionalPathTool(service, subcommand);
|
|
85
|
+
if (dedicated) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`gog ${service} ${subcommand} reads or writes a local path given as a positional argument, so it is not available through gog_${service}_run. Use ${dedicated}, which confines the path to GOG_FILE_ROOTS.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
for (let i = 0; i < args.length; i++) {
|
|
91
|
+
const arg = args[i];
|
|
92
|
+
if (SHORT_PATH_CLUSTER.test(arg)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`The flag ${arg} is not allowed here: spell a path flag out as --out or --file (e.g. --out=<path>) so its path can be checked against GOG_FILE_ROOTS.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const match = LONG_FLAG.exec(arg);
|
|
98
|
+
if (!match) continue;
|
|
99
|
+
const name = match[1].toLowerCase();
|
|
100
|
+
if (EXEC_FLAGS.has(name)) {
|
|
101
|
+
throw new Error(`The flag --${name} is not allowed here: it runs a local program chosen by the caller.`);
|
|
102
|
+
}
|
|
103
|
+
const isJson = name.endsWith('-json');
|
|
104
|
+
if (!isJson && !isPathFlag(service, name)) continue;
|
|
105
|
+
let value = match[2];
|
|
106
|
+
if (value === undefined) {
|
|
107
|
+
value = args[i + 1];
|
|
108
|
+
if (value === undefined) continue;
|
|
109
|
+
i++;
|
|
110
|
+
}
|
|
111
|
+
if (isJson) {
|
|
112
|
+
// Inline JSON passes; `@path` reads a file, `@-` reads stdin.
|
|
113
|
+
if (value.startsWith('@')) confineFlagValue(`--${name}`, value.slice(1));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
confineFlagValue(`--${name}`, value);
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type { ChildProcess } from 'node:child_process';
|
|
|
2
2
|
import { delimiter, join } from 'node:path';
|
|
3
3
|
import { currentCallSignal, killOnCancel, parseBoolEnv, readEnvVar, redactSecrets as redactSharedSecrets } from '@chrischall/mcp-utils';
|
|
4
4
|
import { naiveSourceTimeZone } from './timestamps.js';
|
|
5
|
+
import { forbiddenArgReason } from './arg-guard.js';
|
|
6
|
+
import { isGogPositional, type GogPositional } from './argv.js';
|
|
7
|
+
|
|
8
|
+
export type { GogPositional } from './argv.js';
|
|
5
9
|
|
|
6
10
|
export type Spawner = (
|
|
7
11
|
command: string,
|
|
@@ -55,10 +59,10 @@ export interface GogFileArg {
|
|
|
55
59
|
positional?: boolean;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
export type GogArg = string | GogFileArg;
|
|
62
|
+
export type GogArg = string | GogFileArg | GogPositional;
|
|
59
63
|
|
|
60
64
|
export function isGogFileArg(arg: GogArg): arg is GogFileArg {
|
|
61
|
-
return typeof arg !== 'string';
|
|
65
|
+
return typeof arg !== 'string' && arg.kind === 'file';
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
export interface RunOptions {
|
|
@@ -97,6 +101,15 @@ export interface RunOptions {
|
|
|
97
101
|
// (see OPAQUE_FIELD_VALUE) — so a field carrying real prose, which is where a
|
|
98
102
|
// real leaked token would live, still gets redacted normally.
|
|
99
103
|
opaqueFields?: readonly string[];
|
|
104
|
+
// Inject gog's global --gmail-no-send, which blocks every Gmail send
|
|
105
|
+
// (send, reply, forward, drafts send, users.messages.send via api call) at
|
|
106
|
+
// runtime. Set by the escape hatches so none of them can dispatch mail
|
|
107
|
+
// around the confirmation rail.
|
|
108
|
+
gmailNoSend?: boolean;
|
|
109
|
+
// Ceiling on the bytes gog may write to stdout. Past it the child is killed
|
|
110
|
+
// and the call rejects, so one oversized download (a multi-GB Drive file read
|
|
111
|
+
// through runBinary) cannot exhaust this process's memory.
|
|
112
|
+
maxOutputBytes?: number;
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
const TIMEOUT_MS = 30_000;
|
|
@@ -300,8 +313,8 @@ function formatTimeout(ms: number): string {
|
|
|
300
313
|
// plain argv, and remove the temp dir afterwards — on success, on a non-zero
|
|
301
314
|
// exit, and on timeout alike. A leaked temp file holds user email content.
|
|
302
315
|
async function spawnWithTempFiles(
|
|
303
|
-
args:
|
|
304
|
-
opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
|
|
316
|
+
args: Array<string | GogFileArg>,
|
|
317
|
+
opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean; maxOutputBytes?: number },
|
|
305
318
|
): Promise<string> {
|
|
306
319
|
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
|
|
307
320
|
const { tmpdir } = await import('node:os');
|
|
@@ -350,8 +363,8 @@ async function spawnWithTempFiles(
|
|
|
350
363
|
// to happen synchronously within the `run()` call, which the fake-timer tests
|
|
351
364
|
// in tests/runner.test.ts depend on.
|
|
352
365
|
function spawnExecutor(
|
|
353
|
-
args:
|
|
354
|
-
opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
|
|
366
|
+
args: Array<string | GogFileArg>,
|
|
367
|
+
opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean; maxOutputBytes?: number },
|
|
355
368
|
): Promise<string> {
|
|
356
369
|
if (args.some(isGogFileArg)) {
|
|
357
370
|
return spawnWithTempFiles(args, opts);
|
|
@@ -365,9 +378,9 @@ function spawnExecutor(
|
|
|
365
378
|
// injected `spawner` bypasses the real child_process spawn.
|
|
366
379
|
async function spawnGog(
|
|
367
380
|
fullArgs: string[],
|
|
368
|
-
opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
|
|
381
|
+
opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean; maxOutputBytes?: number },
|
|
369
382
|
): Promise<string> {
|
|
370
|
-
const { timeout, interactive = false, spawner, binary = false } = opts;
|
|
383
|
+
const { timeout, interactive = false, spawner, binary = false, maxOutputBytes } = opts;
|
|
371
384
|
const spawn = spawner ?? (await import('node:child_process')).spawn as unknown as Spawner;
|
|
372
385
|
const effectiveTimeout = timeout ?? TIMEOUT_MS;
|
|
373
386
|
|
|
@@ -404,7 +417,19 @@ async function spawnGog(
|
|
|
404
417
|
reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
|
|
405
418
|
}, effectiveTimeout);
|
|
406
419
|
|
|
407
|
-
|
|
420
|
+
let stdoutBytes = 0;
|
|
421
|
+
child.stdout!.on('data', (chunk: Buffer) => {
|
|
422
|
+
if (settled) return;
|
|
423
|
+
stdoutBytes += chunk.length;
|
|
424
|
+
if (maxOutputBytes !== undefined && stdoutBytes > maxOutputBytes) {
|
|
425
|
+
settled = true;
|
|
426
|
+
stopWatching();
|
|
427
|
+
child.kill();
|
|
428
|
+
reject(new Error(`gog output exceeded the limit: more than ${maxOutputBytes} bytes`));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
stdoutChunks.push(chunk);
|
|
432
|
+
});
|
|
408
433
|
child.stderr!.on('data', (chunk: Buffer) => { stderrChunks.push(chunk); });
|
|
409
434
|
|
|
410
435
|
child.on('close', (code: number | null) => {
|
|
@@ -458,12 +483,51 @@ async function spawnGog(
|
|
|
458
483
|
// Assemble the full gog argv: the always-injected flags (--json/--color=never,
|
|
459
484
|
// --no-input unless interactive, --readonly when opted in), --account, then the
|
|
460
485
|
// caller's args. Shared by run() and runBinary() so both get identical flags.
|
|
486
|
+
// Split the caller's argv into flag position and positional position.
|
|
487
|
+
// pos()-marked values, positional file args, and anything after an explicit
|
|
488
|
+
// `--` are positionals; they go after ONE `--` at the very end, in their
|
|
489
|
+
// original order, so a value that starts with '-' is data, never a flag
|
|
490
|
+
// (audit BUG-1). Commands and flags keep their relative order. With nothing
|
|
491
|
+
// positional the argv is returned unchanged — no `--` at all.
|
|
492
|
+
function placePositionals(args: GogArg[]): { flags: Array<string | GogFileArg>; positionals: Array<string | GogFileArg> } {
|
|
493
|
+
const flags: Array<string | GogFileArg> = [];
|
|
494
|
+
const positionals: Array<string | GogFileArg> = [];
|
|
495
|
+
let afterSeparator = false;
|
|
496
|
+
for (const arg of args) {
|
|
497
|
+
if (arg === '--') {
|
|
498
|
+
afterSeparator = true;
|
|
499
|
+
} else if (isGogPositional(arg)) {
|
|
500
|
+
positionals.push(arg.value);
|
|
501
|
+
} else if (afterSeparator || (isGogFileArg(arg) && arg.positional)) {
|
|
502
|
+
positionals.push(arg);
|
|
503
|
+
} else {
|
|
504
|
+
flags.push(arg);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return { flags, positionals };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Refuse a safety-control override sitting in FLAG position. The tool layer
|
|
511
|
+
// already vets model-supplied escape-hatch args (arg-guard.ts); this is the
|
|
512
|
+
// backstop for every other path, so no tool can hand gog a `--readonly=false`
|
|
513
|
+
// that would override the `--readonly` injected below (gog takes the LAST
|
|
514
|
+
// value of a repeated flag). Positionals are exempt: after `--` they are data.
|
|
515
|
+
function assertNoSafetyOverrides(flags: Array<string | GogFileArg>): void {
|
|
516
|
+
for (const arg of flags) {
|
|
517
|
+
if (typeof arg !== 'string') continue;
|
|
518
|
+
const reason = forbiddenArgReason(arg);
|
|
519
|
+
if (reason) throw new Error(reason);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
461
523
|
function assembleArgs(
|
|
462
524
|
args: GogArg[],
|
|
463
|
-
opts: { account?: string; interactive: boolean; readonly: boolean },
|
|
464
|
-
):
|
|
525
|
+
opts: { account?: string; interactive: boolean; readonly: boolean; gmailNoSend: boolean },
|
|
526
|
+
): Array<string | GogFileArg> {
|
|
527
|
+
const { flags, positionals } = placePositionals(args);
|
|
528
|
+
assertNoSafetyOverrides(flags);
|
|
465
529
|
const effectiveAccount = opts.account ?? readEnvVar('GOG_ACCOUNT');
|
|
466
|
-
const fullArgs:
|
|
530
|
+
const fullArgs: Array<string | GogFileArg> = ['--json', '--color=never'];
|
|
467
531
|
if (!opts.interactive) {
|
|
468
532
|
fullArgs.push('--no-input');
|
|
469
533
|
}
|
|
@@ -473,15 +537,19 @@ function assembleArgs(
|
|
|
473
537
|
if (opts.readonly || readonlyEnvEnabled()) {
|
|
474
538
|
fullArgs.push('--readonly');
|
|
475
539
|
}
|
|
540
|
+
if (opts.gmailNoSend) {
|
|
541
|
+
fullArgs.push('--gmail-no-send');
|
|
542
|
+
}
|
|
476
543
|
if (effectiveAccount) {
|
|
477
544
|
fullArgs.push('--account', effectiveAccount);
|
|
478
545
|
}
|
|
479
|
-
fullArgs.push(...
|
|
546
|
+
fullArgs.push(...flags);
|
|
547
|
+
if (positionals.length > 0) fullArgs.push('--', ...positionals);
|
|
480
548
|
return fullArgs;
|
|
481
549
|
}
|
|
482
550
|
|
|
483
551
|
export async function run(args: GogArg[], options: RunOptions = {}): Promise<string> {
|
|
484
|
-
const { account, spawner, interactive = false, timeout, readonly = false, redactMode = 'full', opaqueFields } = options;
|
|
552
|
+
const { account, spawner, interactive = false, timeout, readonly = false, redactMode = 'full', opaqueFields, gmailNoSend = false } = options;
|
|
485
553
|
const base = redactMode === 'tokens' ? redactGoogleTokens : redactSecrets;
|
|
486
554
|
// Only OUTPUT carries opaque payloads. An error message is prose by
|
|
487
555
|
// definition, so it always takes the plain redactor — exempting a field there
|
|
@@ -490,7 +558,7 @@ export async function run(args: GogArg[], options: RunOptions = {}): Promise<str
|
|
|
490
558
|
? (text: string): string => redactPreservingOpaqueFields(text, opaqueFields, base)
|
|
491
559
|
: base;
|
|
492
560
|
|
|
493
|
-
const fullArgs = assembleArgs(args, { account, interactive, readonly });
|
|
561
|
+
const fullArgs = assembleArgs(args, { account, interactive, readonly, gmailNoSend });
|
|
494
562
|
|
|
495
563
|
// Redaction wraps the spawn: a successful `gog auth tokens` (or any command
|
|
496
564
|
// echoing a credential) would otherwise return raw Google tokens (ya29.…/1//…)
|
|
@@ -511,7 +579,7 @@ export async function run(args: GogArg[], options: RunOptions = {}): Promise<str
|
|
|
511
579
|
// would corrupt. No redaction: the base64 of a user's own binary file is opaque
|
|
512
580
|
// and has no token shapes to leak.
|
|
513
581
|
export async function runBinary(args: GogArg[], options: RunOptions = {}): Promise<string> {
|
|
514
|
-
const { account, spawner, timeout, readonly = false } = options;
|
|
515
|
-
const fullArgs = assembleArgs(args, { account, interactive: false, readonly });
|
|
516
|
-
return spawnExecutor(fullArgs, { timeout, interactive: false, spawner, binary: true });
|
|
582
|
+
const { account, spawner, timeout, readonly = false, gmailNoSend = false, maxOutputBytes } = options;
|
|
583
|
+
const fullArgs = assembleArgs(args, { account, interactive: false, readonly, gmailNoSend });
|
|
584
|
+
return spawnExecutor(fullArgs, { timeout, interactive: false, spawner, binary: true, maxOutputBytes });
|
|
517
585
|
}
|