gogcli-mcp 4.5.0 → 4.5.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.
@@ -302,10 +302,11 @@ export function formatAccountList(raw: string): string {
302
302
 
303
303
  // Turn a thrown error into a diagnosed error result (`isError: true`): the
304
304
  // error text, an actionable hint when the failure class is recognised (auth /
305
- // transient / off-grid write), and the list of configured accounts. Callers
306
- // that need to surface a failure without going through runOrDiagnose (e.g. a
307
- // pre-write verification read that must abort) can reuse this so the error
308
- // keeps the same diagnostic quality as everywhere else.
305
+ // transient / off-grid write), and — on an auth failure only — the list of
306
+ // configured accounts. Callers that need to surface a failure without going
307
+ // through runOrDiagnose (e.g. a pre-write verification read that must abort)
308
+ // can reuse this so the error keeps the same diagnostic quality as everywhere
309
+ // else.
309
310
  export async function diagnose(err: unknown): Promise<CallToolResult> {
310
311
  const errText = errorText(err);
311
312
 
@@ -333,6 +334,11 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
333
334
  : isGridLimitError
334
335
  ? GRID_LIMIT_HINT
335
336
  : '';
337
+ // The account list answers one question — WHICH account is signed in — so it
338
+ // is appended only when the failure is about that (PRIV-1, fleet-audit
339
+ // #1012). Every other error used to echo every identity on the host to the
340
+ // model, and spawn a `gog auth list`, for nothing.
341
+ if (!isAuthError && !isInvalidGrant) return errorResult(`${errText}${hint}`);
336
342
  try {
337
343
  const accounts = formatAccountList(await run(['auth', 'list']));
338
344
  return errorResult(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);
@@ -94,7 +94,7 @@ describe('logGmailDispatch', () => {
94
94
  logGmailDispatch('gog_gmail_reply', ['me@example.com', 'outside@other.com'], 'me@example.com');
95
95
  const event = loggedEvent();
96
96
  expect(event.hasExternalRecipients).toBe(true);
97
- expect(event.externalRecipients).toEqual(['outside@other.com']);
97
+ expect(event.externalDomains).toEqual(['other.com']);
98
98
  expect(event.externalRecipientCount).toBe(1);
99
99
  });
100
100
 
@@ -102,34 +102,34 @@ describe('logGmailDispatch', () => {
102
102
  process.env.GOG_ACCOUNT = 'me@example.com';
103
103
  logGmailDispatch('gog_gmail_send', ['me@example.com', 'outside@other.com']);
104
104
  const event = loggedEvent();
105
- expect(event.externalRecipients).toEqual(['outside@other.com']);
105
+ expect(event.externalDomains).toEqual(['other.com']);
106
106
  });
107
107
 
108
108
  it('adds every domain in GOG_GMAIL_TRUSTED_DOMAINS on top of the account domain', () => {
109
109
  process.env.GOG_GMAIL_TRUSTED_DOMAINS = 'law-firm.example, partner.example';
110
110
  logGmailDispatch('gog_gmail_reply_all', ['counsel@law-firm.example', 'stranger@random.example'], 'me@example.com');
111
111
  const event = loggedEvent();
112
- expect(event.externalRecipients).toEqual(['stranger@random.example']);
112
+ expect(event.externalDomains).toEqual(['random.example']);
113
113
  });
114
114
 
115
115
  it('reports no external recipients when every address is trusted', () => {
116
116
  logGmailDispatch('gog_gmail_forward', ['me@example.com'], 'me@example.com');
117
117
  const event = loggedEvent();
118
118
  expect(event.hasExternalRecipients).toBe(false);
119
- expect(event.externalRecipients).toEqual([]);
119
+ expect(event.externalDomains).toEqual([]);
120
120
  });
121
121
 
122
122
  it('treats an account with no @ as contributing no trusted domain', () => {
123
123
  logGmailDispatch('gog_gmail_send', ['a@example.com'], 'not-an-email');
124
124
  const event = loggedEvent();
125
- expect(event.externalRecipients).toEqual(['a@example.com']);
125
+ expect(event.externalDomains).toEqual(['example.com']);
126
126
  });
127
127
 
128
128
  it('ignores an empty segment in GOG_GMAIL_TRUSTED_DOMAINS (e.g. a trailing comma)', () => {
129
129
  process.env.GOG_GMAIL_TRUSTED_DOMAINS = 'law-firm.example,,';
130
130
  logGmailDispatch('gog_gmail_send', ['counsel@law-firm.example'], 'me@example.com');
131
131
  const event = loggedEvent();
132
- expect(event.externalRecipients).toEqual([]);
132
+ expect(event.externalDomains).toEqual([]);
133
133
  });
134
134
 
135
135
  it('treats unresolved desktop placeholders as unset env vars', () => {
@@ -137,13 +137,29 @@ describe('logGmailDispatch', () => {
137
137
  process.env.GOG_ACCOUNT = '${user_config.account}';
138
138
  logGmailDispatch('gog_gmail_send', ['person@user_config.trusted_domains']);
139
139
  const event = loggedEvent();
140
- expect(event.externalRecipients).toEqual(['person@user_config.trusted_domains']);
140
+ expect(event.externalDomains).toEqual(['user_config.trusted_domains']);
141
141
  });
142
142
 
143
143
  it('treats a recipient with no @ as external, not a crash', () => {
144
144
  logGmailDispatch('gog_gmail_send', ['not-an-email'], 'me@example.com');
145
145
  const event = loggedEvent();
146
- expect(event.externalRecipients).toEqual(['not-an-email']);
146
+ expect(event.externalRecipientCount).toBe(1);
147
+ expect(event.externalDomains).toEqual(['(no domain)']);
148
+ });
149
+
150
+ // PRIV-1 (fleet-audit #1012): on mcp-host this line is host log data the user
151
+ // never sees, so it names domains, never addresses — the count and the
152
+ // domain are what the audit purpose needs (an unexpected law firm, a
153
+ // wrong-number alias), the local part is a third party's identity.
154
+ it('never writes a recipient address to stderr', () => {
155
+ logGmailDispatch('gog_gmail_reply_all', ['me@example.com', 'counsel@law-firm.example', 'second@law-firm.example', 'Kid@School.example'], 'me@example.com');
156
+ const line = writeSpy.mock.calls[0][0] as string;
157
+ expect(line).not.toMatch(/@/);
158
+ expect(line).not.toContain('externalRecipients');
159
+ const event = loggedEvent();
160
+ expect(event.recipientCount).toBe(4);
161
+ expect(event.externalRecipientCount).toBe(3);
162
+ expect(event.externalDomains).toEqual(['law-firm.example', 'school.example']);
147
163
  });
148
164
  });
149
165
 
@@ -1,14 +1,37 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
2
3
  import { registerApiTools } from '../../src/tools/api.js';
3
4
  import * as runner from '../../src/runner.js';
4
5
  import { createTestHarness } from '@chrischall/mcp-utils/test';
5
6
  import { pos } from '../../src/argv.js';
7
+ import { resetConfirmTokenState } from '../../src/send-confirm-token.js';
8
+ import { CONFIRM_ACTION_INSTRUCTION } from '../../src/dispatch-confirmation.js';
6
9
 
7
10
  vi.mock('../../src/runner.js');
8
11
 
9
12
  const setupHandlers = () => createTestHarness(registerApiTools);
10
13
 
11
- beforeEach(() => vi.clearAllMocks());
14
+ /** A client that CAN be prompted, answering with `answer` and recording the prompt. */
15
+ async function prompted(answer: ElicitResult = { action: 'accept', content: { confirmed: true } }) {
16
+ const seen: ElicitRequest[] = [];
17
+ const harness = await createTestHarness(registerApiTools, { elicitation: async (r) => { seen.push(r); return answer; } });
18
+ const details = () => JSON.parse(seen[0]!.params.message.split('\n').slice(1).join('\n')).details;
19
+ return { harness, seen, details };
20
+ }
21
+ const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
22
+
23
+ const ORIGINAL_ENV = { ...process.env };
24
+
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ process.env = { ...ORIGINAL_ENV };
28
+ delete process.env.MCP_CONFIRM_MODE;
29
+ delete process.env.MCP_CONFIRM_TTL_SECONDS;
30
+ delete process.env.MCP_CONFIRM_SECRET;
31
+ resetConfirmTokenState();
32
+ });
33
+
34
+ afterEach(() => { process.env = ORIGINAL_ENV; });
12
35
 
13
36
  describe('gog_api_list', () => {
14
37
  it('lists the default API set', async () => {
@@ -76,7 +99,7 @@ describe('gog_api_call', () => {
76
99
  await harness.callTool('gog_api_call', {
77
100
  api: 'drive', version: 'v3', method: 'files.create',
78
101
  params: '{"fields":"id"}', body: '{"name":"f"}', scope: 'https://www.googleapis.com/auth/drive',
79
- allowWrite: true, dryRun: true, account: 'a@b.com',
102
+ allowWrite: true, dryRun: true, account: 'a@example.com',
80
103
  });
81
104
  expect(runner.run).toHaveBeenCalledWith(
82
105
  [
@@ -84,13 +107,13 @@ describe('gog_api_call', () => {
84
107
  '--params={"fields":"id"}', '--body={"name":"f"}',
85
108
  '--scope=https://www.googleapis.com/auth/drive', '--allow-write', '--dry-run', '--force',
86
109
  ],
87
- { account: 'a@b.com', gmailNoSend: true },
110
+ { account: 'a@example.com', gmailNoSend: true },
88
111
  );
89
112
  });
90
113
 
91
- it('appends --force last even when dry-run is absent', async () => {
114
+ it('appends --force last even when dry-run is absent, once the user has confirmed', async () => {
92
115
  vi.mocked(runner.run).mockResolvedValue('{}');
93
- const harness = await setupHandlers();
116
+ const { harness } = await prompted();
94
117
  await harness.callTool('gog_api_call', {
95
118
  api: 'drive', version: 'v3', method: 'files.create', allowWrite: true,
96
119
  });
@@ -99,6 +122,108 @@ describe('gog_api_call', () => {
99
122
  expect(passedArgs[passedArgs.length - 1]).toBe('--force');
100
123
  });
101
124
 
125
+ // SEC-2 (fleet-audit #931): allowWrite is a boolean the MODEL sets, and
126
+ // --force skips gog's own confirmation, so a method blocklist could never be
127
+ // complete (events.move/delete, acl.insert, sendAs.create, updateVacation,
128
+ // batchDelete, ...). Every write now asks the user about the exact
129
+ // api/method/params/body before anything is sent.
130
+ describe('the write gate', () => {
131
+ const write = {
132
+ api: 'calendar', version: 'v3', method: 'events.delete',
133
+ params: '{"calendarId":"primary","eventId":"e1","sendUpdates":"all"}',
134
+ };
135
+
136
+ it('prompts with the api, version, method, params and body, then runs once accepted', async () => {
137
+ vi.mocked(runner.run).mockResolvedValue('{}');
138
+ const { harness, details, seen } = await prompted();
139
+ await harness.callTool('gog_api_call', { ...write, body: '{"a":1}', scope: 'https://www.googleapis.com/auth/calendar', allowWrite: true, account: 'a@example.com' });
140
+ expect(seen[0]!.params.message).toMatch(/calendar v3 events\.delete/);
141
+ expect(details()).toEqual({
142
+ api: 'calendar', version: 'v3', method: 'events.delete',
143
+ params: { calendarId: 'primary', eventId: 'e1', sendUpdates: 'all' },
144
+ body: { a: 1 },
145
+ scope: 'https://www.googleapis.com/auth/calendar',
146
+ });
147
+ expect(runner.run).toHaveBeenCalledWith(
148
+ ['api', 'call', pos('calendar'), pos('v3'), pos('events.delete'), `--params=${write.params}`, '--body={"a":1}',
149
+ '--scope=https://www.googleapis.com/auth/calendar', '--allow-write', '--force'],
150
+ { account: 'a@example.com', gmailNoSend: true },
151
+ );
152
+ });
153
+
154
+ it('shows a body that is not JSON (an @file reference) as the string it is', async () => {
155
+ vi.mocked(runner.run).mockResolvedValue('{}');
156
+ const { harness, details } = await prompted({ action: 'decline' });
157
+ await harness.callTool('gog_api_call', { api: 'drive', version: 'v3', method: 'files.create', body: '@/body.json', params: 'not json', allowWrite: true });
158
+ expect(details()).toEqual({ api: 'drive', version: 'v3', method: 'files.create', params: 'not json', body: '@/body.json' });
159
+ });
160
+
161
+ it('bounds an oversized body in the prompt, marking the cut', async () => {
162
+ vi.mocked(runner.run).mockResolvedValue('{}');
163
+ const { harness, details } = await prompted({ action: 'decline' });
164
+ const body = JSON.stringify({ text: 'x'.repeat(3000) });
165
+ await harness.callTool('gog_api_call', { api: 'docs', version: 'v1', method: 'documents.batchUpdate', body, allowWrite: true });
166
+ const shown = details().body as string;
167
+ expect(shown.startsWith(body.slice(0, 2048))).toBe(true);
168
+ expect(shown).toMatch(/more characters not shown\]$/);
169
+ });
170
+
171
+ it('runs nothing when the user declines', async () => {
172
+ const { harness } = await prompted({ action: 'decline' });
173
+ const r = json(await harness.callTool('gog_api_call', { ...write, allowWrite: true }));
174
+ expect(r).toMatchObject({ confirmed: false, cancelled: true, action: 'api.call' });
175
+ expect(runner.run).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it.each([
179
+ ['a read', { api: 'calendar', version: 'v3', method: 'events.list' }],
180
+ ['a dry run', { ...write, allowWrite: true, dryRun: true }],
181
+ ['a write without allowWrite (gog refuses it itself)', { ...write }],
182
+ ])('does not ask for %s', async (_label, args) => {
183
+ process.env.MCP_CONFIRM_MODE = 'refuse';
184
+ vi.mocked(runner.run).mockResolvedValue('{}');
185
+ const harness = await setupHandlers();
186
+ const r = await harness.callTool('gog_api_call', args);
187
+ expect(r.isError).toBeFalsy();
188
+ expect(runner.run).toHaveBeenCalledTimes(1);
189
+ });
190
+
191
+ it('refuses a client that cannot be prompted under MCP_CONFIRM_MODE=refuse, naming the way through', async () => {
192
+ process.env.MCP_CONFIRM_MODE = 'refuse';
193
+ const harness = await setupHandlers();
194
+ const r = json(await harness.callTool('gog_api_call', { ...write, allowWrite: true }));
195
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'api.call' });
196
+ expect(r.note).toMatch(/dryRun/);
197
+ expect(runner.run).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it('token fallback: previews in full, refuses a changed payload, runs on phase 2', async () => {
201
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
202
+ vi.mocked(runner.run).mockResolvedValue('{}');
203
+ const harness = await setupHandlers();
204
+ const args = { ...write, body: '{"a":1}', allowWrite: true };
205
+ const p1 = json(await harness.callTool('gog_api_call', args));
206
+ expect(p1).toMatchObject({
207
+ status: 'confirmation-required',
208
+ preview: {
209
+ api: 'calendar', version: 'v3', method: 'events.delete',
210
+ params: { calendarId: 'primary', eventId: 'e1', sendUpdates: 'all' },
211
+ body: { a: 1 },
212
+ },
213
+ instruction: CONFIRM_ACTION_INSTRUCTION,
214
+ });
215
+ expect(runner.run).not.toHaveBeenCalled();
216
+ expect(json(await harness.callTool('gog_api_call', { ...args, body: '{"a":2}', confirmToken: p1.confirmToken })))
217
+ .toMatchObject({ error: 'DRAFT_CHANGED' });
218
+ expect(json(await harness.callTool('gog_api_call', { ...args, params: '{"calendarId":"primary","eventId":"e2"}', confirmToken: p1.confirmToken })))
219
+ .toMatchObject({ error: 'DRAFT_CHANGED' });
220
+ expect(runner.run).not.toHaveBeenCalled();
221
+ await harness.callTool('gog_api_call', { ...args, confirmToken: p1.confirmToken });
222
+ expect(runner.run).toHaveBeenCalledTimes(1);
223
+ expect(vi.mocked(runner.run).mock.calls[0][0]).toContain('--allow-write');
224
+ });
225
+ });
226
+
102
227
  it('returns error text on failure', async () => {
103
228
  vi.mocked(runner.run).mockRejectedValue(new Error('Call failed'));
104
229
  const harness = await setupHandlers();
@@ -7,6 +7,10 @@ import { pos } from '../../src/argv.js';
7
7
  vi.mock('../../src/runner.js');
8
8
 
9
9
  const setupHandlers = () => createTestHarness(registerAppScriptTools);
10
+ // gog_appscript_run_function asks first (dispatch-gates-more.test.ts covers the prompt itself).
11
+ const acceptingHandlers = () => createTestHarness(registerAppScriptTools, {
12
+ elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
13
+ });
10
14
 
11
15
  beforeEach(() => vi.clearAllMocks());
12
16
 
@@ -114,14 +118,14 @@ describe('gog_appscript_versions', () => {
114
118
  describe('gog_appscript_run_function', () => {
115
119
  it('runs a deployed function', async () => {
116
120
  vi.mocked(runner.run).mockResolvedValue('{}');
117
- const harness = await setupHandlers();
121
+ const harness = await acceptingHandlers();
118
122
  await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork' });
119
123
  expect(runner.run).toHaveBeenCalledWith(['appscript', 'run', pos('S1'), pos('doWork')], { account: undefined });
120
124
  });
121
125
 
122
126
  it('passes params and --dev-mode', async () => {
123
127
  vi.mocked(runner.run).mockResolvedValue('{}');
124
- const harness = await setupHandlers();
128
+ const harness = await acceptingHandlers();
125
129
  await harness.callTool('gog_appscript_run_function', {
126
130
  scriptId: 'S1', functionName: 'doWork', params: '["a",1]', devMode: true,
127
131
  });