gogcli-mcp 4.4.0 → 4.5.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 +292 -200
- package/dist/lib.js +258 -166
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +40 -113
- package/src/gmail-dispatch-guard.ts +9 -9
- package/src/send-confirm-token.ts +17 -155
- package/tests/gmail-dispatch-guard.test.ts +36 -17
- package/tests/send-confirm-token.test.ts +17 -197
- package/tests/tools/dispatch-gates.test.ts +22 -12
- package/tests/tools/gmail-confirm-token.test.ts +13 -11
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
|
|
4
|
-
import { callerAcceptsFormElicitation, readEnvVar, requireConfirmation, textResult } from '@chrischall/mcp-utils';
|
|
5
|
-
import { z } from 'zod';
|
|
6
4
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from './send-confirm-token.js';
|
|
5
|
+
CONFIRM_TOKEN_INSTRUCTION,
|
|
6
|
+
confirmationFromEnv,
|
|
7
|
+
readEnvVar,
|
|
8
|
+
requireConfirmationWithFallback,
|
|
9
|
+
type ConfirmSubject,
|
|
10
|
+
} from '@chrischall/mcp-utils';
|
|
11
|
+
import { confirmSpentStore } from './send-confirm-token.js';
|
|
15
12
|
|
|
16
13
|
// ============================================================================
|
|
17
14
|
// THE DISPATCH RAIL, service-neutral: every tool that reaches another person
|
|
@@ -20,9 +17,11 @@ import {
|
|
|
20
17
|
//
|
|
21
18
|
// Elicitation is primary: the host shows the preview and the model never holds
|
|
22
19
|
// the approval. On a client that declares no elicitation (claude.ai, measured),
|
|
23
|
-
// the call
|
|
24
|
-
//
|
|
25
|
-
//
|
|
20
|
+
// the call site's `DispatchTokenFallback` runs the two-phase preview +
|
|
21
|
+
// confirmToken flow instead (mcp-utils' confirmationFromEnv +
|
|
22
|
+
// requireConfirmationWithFallback), as the fleet's MCP_CONFIRM_MODE says:
|
|
23
|
+
// ask-user (default), auto, or refuse. A call site with no fallback (the
|
|
24
|
+
// forwarding filter) is always refused on such a client.
|
|
26
25
|
// ============================================================================
|
|
27
26
|
|
|
28
27
|
// Bound on the body text shown in a confirmation prompt. Enough to read what is
|
|
@@ -100,39 +99,23 @@ export const CONFIRM_SEND_INSTRUCTION =
|
|
|
100
99
|
+ 'Then call again with confirmToken.';
|
|
101
100
|
|
|
102
101
|
/** The same instruction for a dispatch that is not mail (a share, an invitation, a post). */
|
|
103
|
-
export const CONFIRM_ACTION_INSTRUCTION =
|
|
104
|
-
'Show this preview to the user verbatim and proceed only after they explicitly approve in chat. '
|
|
105
|
-
+ 'Then call again with confirmToken.';
|
|
106
|
-
|
|
107
|
-
const FALLBACK_HINT = 'Or set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation.';
|
|
102
|
+
export const CONFIRM_ACTION_INSTRUCTION = CONFIRM_TOKEN_INSTRUCTION;
|
|
108
103
|
|
|
109
104
|
/** Appended to each gated tool's description. */
|
|
110
105
|
export const CONFIRM_FALLBACK_DESCRIPTION =
|
|
111
|
-
' If the client cannot show that prompt (no MCP elicitation, e.g. claude.ai)
|
|
112
|
-
+ '
|
|
113
|
-
+ '
|
|
114
|
-
+ '
|
|
115
|
-
+ '
|
|
116
|
-
+ '
|
|
106
|
+
' If the client cannot show that prompt (no MCP elicitation, e.g. claude.ai), a two-step flow applies instead: call '
|
|
107
|
+
+ 'WITHOUT confirmToken and nothing is sent or changed — the result has status "confirmation-required", the full '
|
|
108
|
+
+ 'preview and a confirmToken. Follow its instruction (by default: show the preview to the user verbatim and only '
|
|
109
|
+
+ 'after they explicitly approve it in chat, call again with the SAME arguments plus confirmToken). The tool re-reads '
|
|
110
|
+
+ 'what it would act on and refuses (DRAFT_CHANGED, with a fresh preview and token) if it changed; TOKEN_EXPIRED / '
|
|
111
|
+
+ 'TOKEN_REUSED / TOKEN_INVALID also do nothing. A server set to MCP_CONFIRM_MODE=refuse refuses instead.';
|
|
117
112
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
+ 'has seen that preview and explicitly approved it in chat — never on the first call, never invented, never '
|
|
122
|
-
+ 'reused. Call again with the same arguments. Ignored when the client supports elicitation.',
|
|
123
|
-
);
|
|
113
|
+
// The schema input every gated tool adds: mcp-utils' own, so its wording and
|
|
114
|
+
// the helper that reads it cannot drift apart.
|
|
115
|
+
export { confirmTokenParam } from '@chrischall/mcp-utils';
|
|
124
116
|
|
|
125
117
|
/** What the fallback binds a token to — recomputed from a fresh read on every call. */
|
|
126
|
-
export
|
|
127
|
-
/** The draftId / messageId / fileId / eventId / query the dispatch acts on. */
|
|
128
|
-
target: string;
|
|
129
|
-
/** A version that rotates on edit: a draft's messageId, an event's etag. */
|
|
130
|
-
revision?: string;
|
|
131
|
-
/** Canonical send payload; its SHA-256 is bound into the token. */
|
|
132
|
-
payload: unknown;
|
|
133
|
-
/** The complete preview shown to the user. */
|
|
134
|
-
preview: Record<string, unknown>;
|
|
135
|
-
}
|
|
118
|
+
export type TokenSubject = ConfirmSubject;
|
|
136
119
|
|
|
137
120
|
/**
|
|
138
121
|
* Opt-in second rail for a client that cannot be prompted. `subject` is only
|
|
@@ -149,66 +132,6 @@ export interface DispatchTokenFallback {
|
|
|
149
132
|
subject: () => TokenSubject | CallToolResult | Promise<TokenSubject | CallToolResult>;
|
|
150
133
|
}
|
|
151
134
|
|
|
152
|
-
const TOKEN_ERROR_NOTE: Record<Exclude<ConfirmTokenError, 'DRAFT_CHANGED'>, string> = {
|
|
153
|
-
TOKEN_EXPIRED: 'Nothing was sent or changed: the confirmToken expired. Call again WITHOUT confirmToken for a fresh preview, '
|
|
154
|
-
+ 'and ask the user to approve it again.',
|
|
155
|
-
TOKEN_REUSED: 'Nothing was sent or changed by this call: this confirmToken was already used, and one approval acts once. '
|
|
156
|
-
+ 'If doing it again is really intended, call again WITHOUT confirmToken and get a new approval.',
|
|
157
|
-
TOKEN_INVALID: 'Nothing was sent or changed: this confirmToken was not issued by this server for this tool, account and '
|
|
158
|
-
+ 'target (or the server has restarted since). Call again WITHOUT confirmToken for a fresh preview and approval.',
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
const DRAFT_CHANGED_NOTE = {
|
|
162
|
-
'message-id-rotated': 'Nothing was sent or changed: the target was edited since the user approved it (a draft\'s '
|
|
163
|
-
+ 'messageId or an event\'s version rotated), so what would happen is not what they saw.',
|
|
164
|
-
'payload-changed': 'Nothing was sent or changed: what would happen no longer matches what the user approved.',
|
|
165
|
-
} as const;
|
|
166
|
-
|
|
167
|
-
function isToolResult(value: TokenSubject | CallToolResult): value is CallToolResult {
|
|
168
|
-
return Array.isArray((value as CallToolResult).content);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function rejection(data: Record<string, unknown>): CallToolResult {
|
|
172
|
-
return { ...textResult({ status: 'confirmation-rejected', confirmed: false, dispatched: false, ...data }), isError: true };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async function tokenConfirmation(op: string, fallback: DispatchTokenFallback): Promise<CallToolResult | undefined> {
|
|
176
|
-
const subject = await fallback.subject();
|
|
177
|
-
if (isToolResult(subject)) return subject;
|
|
178
|
-
const binding: ConfirmBinding = {
|
|
179
|
-
tool: fallback.tool,
|
|
180
|
-
account: fallback.account ?? readEnvVar('GOG_ACCOUNT') ?? '',
|
|
181
|
-
target: subject.target,
|
|
182
|
-
...(subject.revision === undefined ? {} : { revision: subject.revision }),
|
|
183
|
-
payloadHash: hashSendPayload(subject.payload),
|
|
184
|
-
};
|
|
185
|
-
const phaseOne = () => {
|
|
186
|
-
const { token, expiresAt } = issueConfirmToken(binding);
|
|
187
|
-
return {
|
|
188
|
-
action: op,
|
|
189
|
-
preview: subject.preview,
|
|
190
|
-
confirmToken: token,
|
|
191
|
-
expiresAt,
|
|
192
|
-
ttlSeconds: confirmTokenTtlSeconds(),
|
|
193
|
-
instruction: fallback.instruction ?? CONFIRM_ACTION_INSTRUCTION,
|
|
194
|
-
};
|
|
195
|
-
};
|
|
196
|
-
if (!fallback.confirmToken) {
|
|
197
|
-
return textResult({ status: 'confirmation-required', confirmed: false, dispatched: false, ...phaseOne() });
|
|
198
|
-
}
|
|
199
|
-
const verdict = verifyConfirmToken(fallback.confirmToken, binding);
|
|
200
|
-
if (verdict.ok) return undefined;
|
|
201
|
-
if (verdict.error === 'DRAFT_CHANGED') {
|
|
202
|
-
return rejection({
|
|
203
|
-
error: 'DRAFT_CHANGED',
|
|
204
|
-
reason: verdict.reason,
|
|
205
|
-
note: `${DRAFT_CHANGED_NOTE[verdict.reason!]} The current preview and a fresh confirmToken are below.`,
|
|
206
|
-
...phaseOne(),
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
return rejection({ error: verdict.error, action: op, note: TOKEN_ERROR_NOTE[verdict.error] });
|
|
210
|
-
}
|
|
211
|
-
|
|
212
135
|
/**
|
|
213
136
|
* The refusal an escape hatch (`gog_<service>_run`, `gog_api_call`) gives for an
|
|
214
137
|
* action a dedicated tool gates. Without it, the run tool is a way around the
|
|
@@ -242,29 +165,33 @@ export interface DispatchConfirmationOptions {
|
|
|
242
165
|
* Ask the user before a dispatch. `undefined` means proceed; anything else is
|
|
243
166
|
* the result to return unchanged.
|
|
244
167
|
*
|
|
245
|
-
* Elicitation stays the primary path and is untouched.
|
|
246
|
-
* declares it cannot be prompted AND a `fallback` is supplied
|
|
247
|
-
*
|
|
248
|
-
*
|
|
168
|
+
* Elicitation stays the primary path and is untouched. When the caller
|
|
169
|
+
* declares it cannot be prompted AND a `fallback` is supplied, MCP_CONFIRM_MODE
|
|
170
|
+
* decides: ask-user (default) or auto run the two-phase token flow; refuse
|
|
171
|
+
* refuses and names the switch. Without a fallback it is always refused.
|
|
249
172
|
*/
|
|
250
173
|
export async function requireDispatchConfirmation(
|
|
251
174
|
ctx: ServerContext,
|
|
252
175
|
options: DispatchConfirmationOptions,
|
|
253
176
|
): Promise<InputRequiredResult | CallToolResult | undefined> {
|
|
254
177
|
const { action, fallback } = options;
|
|
255
|
-
|
|
256
|
-
return tokenConfirmation(action, fallback);
|
|
257
|
-
}
|
|
258
|
-
const note = fallback
|
|
259
|
-
? [options.unsupportedNote, FALLBACK_HINT].filter(Boolean).join(' ')
|
|
260
|
-
: options.unsupportedNote;
|
|
261
|
-
return requireConfirmation(ctx, {
|
|
178
|
+
const confirmation = {
|
|
262
179
|
action,
|
|
263
180
|
message: options.message,
|
|
264
181
|
details: options.details,
|
|
265
182
|
confirmationLabel: options.confirmationLabel,
|
|
266
|
-
...(
|
|
267
|
-
}
|
|
183
|
+
...(options.unsupportedNote ? { unsupportedNote: options.unsupportedNote } : {}),
|
|
184
|
+
};
|
|
185
|
+
if (!fallback) return requireConfirmationWithFallback(ctx, confirmation);
|
|
186
|
+
return requireConfirmationWithFallback(ctx, confirmationFromEnv({
|
|
187
|
+
...confirmation,
|
|
188
|
+
tool: fallback.tool,
|
|
189
|
+
account: fallback.account ?? readEnvVar('GOG_ACCOUNT') ?? '',
|
|
190
|
+
confirmToken: fallback.confirmToken,
|
|
191
|
+
subject: fallback.subject,
|
|
192
|
+
instruction: fallback.instruction ?? CONFIRM_ACTION_INSTRUCTION,
|
|
193
|
+
spent: confirmSpentStore(),
|
|
194
|
+
}));
|
|
268
195
|
}
|
|
269
196
|
|
|
270
197
|
// The single place a CallToolResult's text is pulled back out, for the tools
|
|
@@ -32,11 +32,11 @@ export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './di
|
|
|
32
32
|
// user's accepted confirmation dispatches. The confirmation is never a tool
|
|
33
33
|
// argument, so a model cannot bypass the user by setting a boolean itself.
|
|
34
34
|
//
|
|
35
|
-
// The one exception
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// IS a tool argument; see
|
|
39
|
-
// not guarantee.
|
|
35
|
+
// The one exception: a client that cannot be prompted gets a two-phase preview
|
|
36
|
+
// + confirmToken instead of a refusal (`DispatchTokenFallback`; mcp-utils'
|
|
37
|
+
// confirmationFromEnv), unless the server sets MCP_CONFIRM_MODE=refuse. There
|
|
38
|
+
// the approval IS a tool argument; see mcp-utils' confirm-token module for
|
|
39
|
+
// exactly what the token does and does not guarantee.
|
|
40
40
|
//
|
|
41
41
|
// A CLIENT THAT CANNOT SHOW THAT PROMPT gets a sentence rather than a prompt
|
|
42
42
|
// it will refuse to deliver (`unsupportedNote`, mcp-utils `requireConfirmation`).
|
|
@@ -123,10 +123,10 @@ const UNSUPPORTED_NOTE: Partial<Record<GmailDispatchOp, string>> = {
|
|
|
123
123
|
/**
|
|
124
124
|
* Apply the shared stateless confirmation flow with Gmail-specific copy.
|
|
125
125
|
*
|
|
126
|
-
* Elicitation stays the primary path and is untouched.
|
|
127
|
-
* declares it cannot be prompted AND a `fallback` is supplied
|
|
128
|
-
*
|
|
129
|
-
*
|
|
126
|
+
* Elicitation stays the primary path and is untouched. When the caller
|
|
127
|
+
* declares it cannot be prompted AND a `fallback` is supplied, MCP_CONFIRM_MODE
|
|
128
|
+
* decides: ask-user (default) or auto run the two-phase token flow; refuse
|
|
129
|
+
* refuses and names the switch.
|
|
130
130
|
*/
|
|
131
131
|
export async function requireGmailDispatchConfirmation(
|
|
132
132
|
ctx: ServerContext,
|
|
@@ -1,167 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
1
|
+
import { createSpentTokenStore, type SpentTokenStore } from '@chrischall/mcp-utils';
|
|
3
2
|
|
|
4
3
|
// ============================================================================
|
|
5
|
-
// THE TOKEN FALLBACK
|
|
4
|
+
// THE TOKEN FALLBACK's gogcli-mcp half, which is now only its spent-token store.
|
|
5
|
+
// Everything else is the fleet's shared layer in @chrischall/mcp-utils
|
|
6
|
+
// (`confirmationFromEnv` + `requireConfirmationWithFallback`), configured by the
|
|
7
|
+
// same three variables as every other fleet server:
|
|
6
8
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// returns the full preview plus a token and sends nothing; phase 2 presents the
|
|
11
|
-
// token, the tool RE-READS what it would send, and dispatches only if that still
|
|
12
|
-
// hashes to what the token was issued for.
|
|
9
|
+
// MCP_CONFIRM_MODE ask-user (default) | auto | refuse
|
|
10
|
+
// MCP_CONFIRM_TTL_SECONDS token lifetime, default 600
|
|
11
|
+
// MCP_CONFIRM_SECRET HMAC key; default random per process
|
|
13
12
|
//
|
|
14
|
-
// WHAT THIS DOES AND DOES NOT PROVE
|
|
15
|
-
// tool argument, so the gate is the model honouring
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// messageId rotates), a changed recipient or body, or a swapped attachment
|
|
19
|
-
// between the two calls all refuse. Caller-supplied files are bound by content
|
|
20
|
-
// hash; a stored draft's attachments by name and size, since Gmail's
|
|
21
|
-
// attachment ids are unstable and any edit rotates the messageId anyway. One
|
|
22
|
-
// approval acts once, for one tool, account and target, within the TTL — per
|
|
23
|
-
// PROCESS: spent tokens live in memory, so with a shared GOG_CONFIRM_SECRET a
|
|
24
|
-
// restart or a second instance would accept a spent token again until it
|
|
25
|
-
// expires. That is why the mode is opt-in.
|
|
13
|
+
// WHAT THIS DOES AND DOES NOT PROVE: see mcp-utils' confirm-token module. The
|
|
14
|
+
// approval is a tool argument, so under ask-user the gate is the model honouring
|
|
15
|
+
// "show this to the user and wait"; the token guarantees what happens matches
|
|
16
|
+
// what was previewed, once, for one tool, account and target.
|
|
26
17
|
// ============================================================================
|
|
27
18
|
|
|
28
|
-
|
|
19
|
+
const spent = createSpentTokenStore();
|
|
29
20
|
|
|
30
|
-
/**
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
account: string;
|
|
34
|
-
/** The draftId / messageId / query the dispatch acts on. */
|
|
35
|
-
target: string;
|
|
36
|
-
/** A version of the target that rotates on edit — a draft's messageId. */
|
|
37
|
-
revision?: string;
|
|
38
|
-
/** {@link hashSendPayload} of the canonical send payload. */
|
|
39
|
-
payloadHash: string;
|
|
21
|
+
/** This server's spent-token store (one per process). */
|
|
22
|
+
export function confirmSpentStore(): SpentTokenStore {
|
|
23
|
+
return spent;
|
|
40
24
|
}
|
|
41
25
|
|
|
42
|
-
|
|
43
|
-
| { ok: true }
|
|
44
|
-
| { ok: false; error: ConfirmTokenError; reason?: 'payload-changed' | 'message-id-rotated' };
|
|
45
|
-
|
|
46
|
-
export const CONFIRM_TOKEN_TTL_DEFAULT_SECONDS = 600;
|
|
47
|
-
|
|
48
|
-
const PREFIX = 'gct1';
|
|
49
|
-
|
|
50
|
-
type Claims = { t: string; a: string; g: string; r?: string; h: string; iat: number; exp: number; n: string };
|
|
51
|
-
|
|
52
|
-
let secret: Buffer | undefined;
|
|
53
|
-
// nonce → expiry (ms). Entries are dropped once they would have expired
|
|
54
|
-
// anyway, so the set is bounded by the tokens spent within one TTL. In memory
|
|
55
|
-
// only: see the header on GOG_CONFIRM_SECRET and restarts.
|
|
56
|
-
const spent = new Map<string, number>();
|
|
57
|
-
|
|
58
|
-
/** GOG_SEND_CONFIRM_FALLBACK=token turns the fallback on. Anything else is off. */
|
|
59
|
-
export function sendConfirmFallbackEnabled(): boolean {
|
|
60
|
-
return readEnvVar('GOG_SEND_CONFIRM_FALLBACK')?.trim().toLowerCase() === 'token';
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function confirmTokenTtlSeconds(): number {
|
|
64
|
-
const raw = readEnvVar('GOG_CONFIRM_TTL_SECONDS')?.trim();
|
|
65
|
-
if (raw && /^\d+$/.test(raw)) {
|
|
66
|
-
const n = Number(raw);
|
|
67
|
-
if (n > 0) return n;
|
|
68
|
-
}
|
|
69
|
-
return CONFIRM_TOKEN_TTL_DEFAULT_SECONDS;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// GOG_CONFIRM_SECRET ends in _SECRET, so runner.ts strips it from every gog
|
|
73
|
-
// child — it never leaves this process.
|
|
74
|
-
function hmacKey(): Buffer {
|
|
75
|
-
if (!secret) {
|
|
76
|
-
const configured = readEnvVar('GOG_CONFIRM_SECRET');
|
|
77
|
-
secret = configured ? Buffer.from(configured, 'utf8') : randomBytes(32);
|
|
78
|
-
}
|
|
79
|
-
return secret;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Test seam: forget the secret (a "restart") and every spent token. */
|
|
26
|
+
/** Test seam: forget every spent token. */
|
|
83
27
|
export function resetConfirmTokenState(): void {
|
|
84
|
-
secret = undefined;
|
|
85
28
|
spent.clear();
|
|
86
29
|
}
|
|
87
|
-
|
|
88
|
-
// Sorted keys, undefined dropped: the same payload re-read on phase 2 must
|
|
89
|
-
// hash identically however its object was assembled.
|
|
90
|
-
function canonicalize(value: unknown): unknown {
|
|
91
|
-
if (Array.isArray(value)) return value.map(canonicalize);
|
|
92
|
-
if (value && typeof value === 'object') {
|
|
93
|
-
const out: Record<string, unknown> = {};
|
|
94
|
-
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
95
|
-
const v = (value as Record<string, unknown>)[key];
|
|
96
|
-
if (v !== undefined) out[key] = canonicalize(v);
|
|
97
|
-
}
|
|
98
|
-
return out;
|
|
99
|
-
}
|
|
100
|
-
return value;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** SHA-256 (hex) of the canonical JSON of a send payload. */
|
|
104
|
-
export function hashSendPayload(payload: unknown): string {
|
|
105
|
-
return createHash('sha256').update(JSON.stringify(canonicalize(payload))).digest('hex');
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function sign(body: string): string {
|
|
109
|
-
return createHmac('sha256', hmacKey()).update(`${PREFIX}.${body}`).digest('base64url');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function issueConfirmToken(binding: ConfirmBinding, now = Date.now()): { token: string; expiresAt: string } {
|
|
113
|
-
const exp = now + confirmTokenTtlSeconds() * 1000;
|
|
114
|
-
const claims: Claims = {
|
|
115
|
-
t: binding.tool,
|
|
116
|
-
a: binding.account,
|
|
117
|
-
g: binding.target,
|
|
118
|
-
...(binding.revision === undefined ? {} : { r: binding.revision }),
|
|
119
|
-
h: binding.payloadHash,
|
|
120
|
-
iat: now,
|
|
121
|
-
exp,
|
|
122
|
-
n: randomBytes(16).toString('base64url'),
|
|
123
|
-
};
|
|
124
|
-
const body = Buffer.from(JSON.stringify(claims), 'utf8').toString('base64url');
|
|
125
|
-
return { token: `${PREFIX}.${body}.${sign(body)}`, expiresAt: new Date(exp).toISOString() };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function signatureMatches(body: string, sig: string): boolean {
|
|
129
|
-
const expected = Buffer.from(sign(body), 'utf8');
|
|
130
|
-
const actual = Buffer.from(sig, 'utf8');
|
|
131
|
-
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function parseClaims(body: string): Claims | undefined {
|
|
135
|
-
try {
|
|
136
|
-
return JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Claims;
|
|
137
|
-
} catch {
|
|
138
|
-
return undefined;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Check a phase-2 token against the binding recomputed from a fresh re-read.
|
|
144
|
-
* Only an `ok` verdict consumes the token; a DRAFT_CHANGED leaves it unspent,
|
|
145
|
-
* because the approval it carries is still true of the content it names.
|
|
146
|
-
*/
|
|
147
|
-
export function verifyConfirmToken(token: string, binding: ConfirmBinding, now = Date.now()): ConfirmTokenVerdict {
|
|
148
|
-
for (const [nonce, exp] of spent) if (exp < now) spent.delete(nonce);
|
|
149
|
-
|
|
150
|
-
const parts = token.split('.');
|
|
151
|
-
if (parts.length !== 3 || parts[0] !== PREFIX || !parts[1] || !parts[2]) return { ok: false, error: 'TOKEN_INVALID' };
|
|
152
|
-
const [, body, sig] = parts as [string, string, string];
|
|
153
|
-
if (!signatureMatches(body, sig)) return { ok: false, error: 'TOKEN_INVALID' };
|
|
154
|
-
const claims = parseClaims(body);
|
|
155
|
-
if (!claims) return { ok: false, error: 'TOKEN_INVALID' };
|
|
156
|
-
|
|
157
|
-
if (claims.t !== binding.tool || claims.a !== binding.account || claims.g !== binding.target) {
|
|
158
|
-
return { ok: false, error: 'TOKEN_INVALID' };
|
|
159
|
-
}
|
|
160
|
-
if (spent.has(claims.n)) return { ok: false, error: 'TOKEN_REUSED' };
|
|
161
|
-
if (now > claims.exp) return { ok: false, error: 'TOKEN_EXPIRED' };
|
|
162
|
-
if (claims.r !== binding.revision) return { ok: false, error: 'DRAFT_CHANGED', reason: 'message-id-rotated' };
|
|
163
|
-
if (claims.h !== binding.payloadHash) return { ok: false, error: 'DRAFT_CHANGED', reason: 'payload-changed' };
|
|
164
|
-
|
|
165
|
-
spent.set(claims.n, claims.exp);
|
|
166
|
-
return { ok: true };
|
|
167
|
-
}
|
|
@@ -298,7 +298,7 @@ describe('attachmentDetails', () => {
|
|
|
298
298
|
});
|
|
299
299
|
|
|
300
300
|
// ============================================================================
|
|
301
|
-
// THE TOKEN FALLBACK (
|
|
301
|
+
// THE TOKEN FALLBACK (MCP_CONFIRM_MODE, default ask-user). Elicitation stays the
|
|
302
302
|
// primary rail; this only replaces the REFUSAL a client that cannot be prompted
|
|
303
303
|
// would otherwise get.
|
|
304
304
|
// ============================================================================
|
|
@@ -308,9 +308,9 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
|
|
|
308
308
|
|
|
309
309
|
beforeEach(() => {
|
|
310
310
|
process.env = { ...ORIGINAL_ENV };
|
|
311
|
-
delete process.env.
|
|
312
|
-
delete process.env.
|
|
313
|
-
delete process.env.
|
|
311
|
+
delete process.env.MCP_CONFIRM_MODE;
|
|
312
|
+
delete process.env.MCP_CONFIRM_TTL_SECONDS;
|
|
313
|
+
delete process.env.MCP_CONFIRM_SECRET;
|
|
314
314
|
process.env.GOG_ACCOUNT = 'me@example.com';
|
|
315
315
|
resetConfirmTokenState();
|
|
316
316
|
});
|
|
@@ -341,37 +341,56 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
|
|
|
341
341
|
return parse(r);
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
-
it('with
|
|
344
|
+
it('with MCP_CONFIRM_MODE=refuse, keeps the refusal and names the switch', async () => {
|
|
345
|
+
|
|
346
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
345
347
|
const fb = fallback();
|
|
346
348
|
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fb));
|
|
347
349
|
expect(r.reason).toBe('confirmation-unsupported');
|
|
348
|
-
expect(r.note).toContain('
|
|
350
|
+
expect(r.note).toContain('Set MCP_CONFIRM_MODE=ask-user');
|
|
349
351
|
expect(r.note).toContain('still saved');
|
|
350
352
|
expect(fb.subject).not.toHaveBeenCalled();
|
|
351
353
|
});
|
|
352
354
|
|
|
353
355
|
it('names the switch even for an op with no other note (autoreply)', async () => {
|
|
356
|
+
|
|
357
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
354
358
|
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.autoreply', {}, fallback()));
|
|
355
|
-
expect(r.note).toContain('
|
|
359
|
+
expect(r.note).toContain('MCP_CONFIRM_MODE=ask-user');
|
|
356
360
|
});
|
|
357
361
|
|
|
358
362
|
it('never offers the switch to a caller that passed no fallback (a forwarding filter)', async () => {
|
|
359
|
-
process.env.
|
|
363
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
360
364
|
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.filter-forward', {}));
|
|
361
365
|
expect(r.reason).toBe('confirmation-unsupported');
|
|
362
|
-
expect(r.note).not.toContain('
|
|
366
|
+
expect(r.note).not.toContain('MCP_CONFIRM_MODE');
|
|
363
367
|
});
|
|
364
368
|
|
|
365
369
|
it('leaves elicitation untouched even with the env set and a token passed', async () => {
|
|
366
|
-
process.env.
|
|
367
|
-
const fb = fallback('
|
|
370
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
371
|
+
const fb = fallback('not-a-real-token');
|
|
368
372
|
expect(await requireGmailDispatchConfirmation(CAN_BE_ASKED, 'gmail.drafts-send', {}, fb))
|
|
369
373
|
.toMatchObject({ resultType: 'input_required' });
|
|
370
374
|
expect(fb.subject).not.toHaveBeenCalled();
|
|
371
375
|
});
|
|
372
376
|
|
|
373
|
-
|
|
374
|
-
|
|
377
|
+
// The fleet default: with MCP_CONFIRM_MODE unset, a client that cannot be
|
|
378
|
+
// prompted gets the two-step flow rather than a refusal.
|
|
379
|
+
it('with MCP_CONFIRM_MODE unset, runs the two-step flow (ask-user is the default)', async () => {
|
|
380
|
+
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback()));
|
|
381
|
+
expect(r).toMatchObject({ status: 'confirmation-required', instruction: CONFIRM_INSTRUCTION });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('with MCP_CONFIRM_MODE=auto, tells the model it may proceed after reviewing the preview', async () => {
|
|
385
|
+
process.env.MCP_CONFIRM_MODE = 'auto';
|
|
386
|
+
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback()));
|
|
387
|
+
expect(r.status).toBe('confirmation-required');
|
|
388
|
+
expect(r.instruction).toMatch(/MCP_CONFIRM_MODE=auto/);
|
|
389
|
+
expect(r.instruction).not.toBe(CONFIRM_INSTRUCTION);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe('with MCP_CONFIRM_MODE=ask-user', () => {
|
|
393
|
+
beforeEach(() => { process.env.MCP_CONFIRM_MODE = 'ask-user'; });
|
|
375
394
|
|
|
376
395
|
it('phase 1 returns the full preview, a token and the instruction, and does not proceed', async () => {
|
|
377
396
|
const r = await phaseOne();
|
|
@@ -385,7 +404,7 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
|
|
|
385
404
|
instruction: CONFIRM_INSTRUCTION,
|
|
386
405
|
});
|
|
387
406
|
expect(r.instruction).toBe('Show this preview to the user verbatim and send only after they explicitly approve in chat. Then call again with confirmToken.');
|
|
388
|
-
expect(r.confirmToken).toMatch(/^
|
|
407
|
+
expect(r.confirmToken).toMatch(/^mcpu\.token\.v1\./);
|
|
389
408
|
expect(typeof r.expiresAt).toBe('string');
|
|
390
409
|
});
|
|
391
410
|
|
|
@@ -400,8 +419,8 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
|
|
|
400
419
|
const r = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken, rotated));
|
|
401
420
|
expect((r as CallToolResult).isError).toBe(true);
|
|
402
421
|
const body = parse(r);
|
|
403
|
-
expect(body).toMatchObject({ status: 'confirmation-rejected', error: 'DRAFT_CHANGED', reason: '
|
|
404
|
-
expect(body.note).toMatch(/
|
|
422
|
+
expect(body).toMatchObject({ status: 'confirmation-rejected', error: 'DRAFT_CHANGED', reason: 'revision-changed', dispatched: false, instruction: CONFIRM_INSTRUCTION });
|
|
423
|
+
expect(body.note).toMatch(/edited since/);
|
|
405
424
|
expect(body.confirmToken).not.toBe(confirmToken);
|
|
406
425
|
expect(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(body.confirmToken, rotated))).toBeUndefined();
|
|
407
426
|
});
|
|
@@ -422,7 +441,7 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
|
|
|
422
441
|
});
|
|
423
442
|
|
|
424
443
|
it('TOKEN_EXPIRED after the TTL', async () => {
|
|
425
|
-
process.env.
|
|
444
|
+
process.env.MCP_CONFIRM_TTL_SECONDS = '60';
|
|
426
445
|
vi.useFakeTimers();
|
|
427
446
|
vi.setSystemTime(new Date('2026-09-24T10:00:00Z'));
|
|
428
447
|
const { confirmToken } = await phaseOne();
|