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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +1340 -262
  4. package/dist/lib.js +1381 -260
  5. package/manifest.json +9 -2
  6. package/package.json +2 -2
  7. package/server.json +2 -2
  8. package/src/arg-guard.ts +68 -0
  9. package/src/argv.ts +27 -0
  10. package/src/attachment-root.ts +111 -0
  11. package/src/attachments.ts +1 -0
  12. package/src/blob-upload.ts +4 -2
  13. package/src/dispatch-confirmation.ts +277 -0
  14. package/src/file-roots.ts +66 -0
  15. package/src/gmail-dispatch-guard.ts +70 -30
  16. package/src/gmail-results.ts +21 -2
  17. package/src/lib.ts +15 -0
  18. package/src/run-path-guard.ts +118 -0
  19. package/src/runner.ts +86 -18
  20. package/src/send-confirm-token.ts +167 -0
  21. package/src/tools/api.ts +60 -7
  22. package/src/tools/appscript.ts +12 -8
  23. package/src/tools/auth.ts +13 -3
  24. package/src/tools/calendar.ts +178 -15
  25. package/src/tools/chat.ts +94 -18
  26. package/src/tools/classroom.ts +110 -28
  27. package/src/tools/contacts.ts +5 -3
  28. package/src/tools/docs.ts +8 -6
  29. package/src/tools/drive.ts +96 -20
  30. package/src/tools/gmail.ts +175 -24
  31. package/src/tools/sheets.ts +10 -8
  32. package/src/tools/slides.ts +14 -9
  33. package/src/tools/tasks.ts +7 -5
  34. package/src/tools/utils.ts +52 -6
  35. package/tests/arg-guard.test.ts +80 -0
  36. package/tests/attachment-root.test.ts +130 -0
  37. package/tests/attachments.test.ts +8 -0
  38. package/tests/blob-upload.test.ts +4 -3
  39. package/tests/file-roots.test.ts +101 -0
  40. package/tests/gmail-dispatch-guard.test.ts +270 -11
  41. package/tests/gmail-results.test.ts +35 -1
  42. package/tests/run-path-guard.test.ts +142 -0
  43. package/tests/runner.test.ts +136 -0
  44. package/tests/send-confirm-token.test.ts +200 -0
  45. package/tests/tools/api.test.ts +74 -8
  46. package/tests/tools/appscript.test.ts +34 -8
  47. package/tests/tools/auth.test.ts +44 -17
  48. package/tests/tools/calendar.test.ts +33 -28
  49. package/tests/tools/chat.test.ts +50 -21
  50. package/tests/tools/classroom.test.ts +43 -39
  51. package/tests/tools/contacts.test.ts +3 -2
  52. package/tests/tools/dispatch-gates.test.ts +396 -0
  53. package/tests/tools/docs.test.ts +44 -15
  54. package/tests/tools/drive.test.ts +110 -20
  55. package/tests/tools/gmail-confirm-token.test.ts +274 -0
  56. package/tests/tools/gmail.test.ts +227 -29
  57. package/tests/tools/run-tool-examples.test.ts +69 -0
  58. package/tests/tools/run-vets.test.ts +131 -0
  59. package/tests/tools/sheets.test.ts +16 -15
  60. package/tests/tools/slides.test.ts +47 -11
  61. package/tests/tools/tasks.test.ts +7 -6
  62. package/tests/tools/utils.test.ts +32 -31
  63. package/vitest.config.ts +5 -0
@@ -4,6 +4,7 @@ import { spawn as mockedSpawn } from 'node:child_process';
4
4
  import { withCallSignal } from '@chrischall/mcp-utils';
5
5
  import { run, runBinary } from '../src/runner.js';
6
6
  import type { Spawner } from '../src/runner.js';
7
+ import { pos } from '../src/argv.js';
7
8
 
8
9
  // The real spawn is dynamically imported inside runner's default executor.
9
10
  // Mock it so the no-spawner/no-executor fallback can be exercised without
@@ -938,6 +939,141 @@ describe('run --readonly (gog 0.31)', () => {
938
939
  });
939
940
  });
940
941
 
942
+ describe('run safety flags', () => {
943
+ it('injects --gmail-no-send when options.gmailNoSend is true', async () => {
944
+ const spawner = makeSpawner(0, '{}');
945
+ await run(['gmail', 'archive', 'm1'], { gmailNoSend: true, spawner });
946
+ expect(spawner).toHaveBeenCalledWith(
947
+ 'gog',
948
+ ['--json', '--color=never', '--no-input', '--gmail-no-send', 'gmail', 'archive', 'm1'],
949
+ expect.any(Object),
950
+ );
951
+ });
952
+
953
+ // Defence in depth behind the tool-level guard (audit SEC-1): whatever tool
954
+ // built the argv, a safety-control override in flag position never reaches
955
+ // gog, where the LAST value of a repeated flag would win.
956
+ it.each([
957
+ ['--readonly=false'],
958
+ ['--disable-commands='],
959
+ ['--enable-commands=gmail.send'],
960
+ ['--gmail-no-send=false'],
961
+ ['--access-token=ya29.x'],
962
+ ['--home=/tmp/x'],
963
+ ['--client=other'],
964
+ ['-a'],
965
+ ])('refuses a caller-supplied %j in flag position without spawning', async (bad) => {
966
+ const spawner = makeSpawner(0, '{}');
967
+ await expect(run(['drive', 'mkdir', 'x', bad], { readonly: true, spawner })).rejects.toThrow(/not allowed/);
968
+ await expect(runBinary(['drive', 'mkdir', 'x', bad], { spawner })).rejects.toThrow(/not allowed/);
969
+ expect(spawner).not.toHaveBeenCalled();
970
+ });
971
+
972
+ // The backstop runs on every tool's argv, so it must match exact control
973
+ // names only: gog_zoom_auth_setup builds --account-id/--client-id/
974
+ // --client-secret, which a prefix match refused before spawning.
975
+ it('lets zoom auth setup credentials flags through to gog', async () => {
976
+ const spawner = makeSpawner(0, '{}');
977
+ await run(['zoom', 'auth', 'setup', '--alias=work', '--account-id=abc', '--client-id=x', '--client-secret=y'], { spawner });
978
+ expect(spawner).toHaveBeenCalledWith(
979
+ 'gog',
980
+ ['--json', '--color=never', '--no-input', 'zoom', 'auth', 'setup', '--alias=work', '--account-id=abc', '--client-id=x', '--client-secret=y'],
981
+ expect.any(Object),
982
+ );
983
+ });
984
+
985
+ it('lets the same text through as a positional after --', async () => {
986
+ const spawner = makeSpawner(0, '{}');
987
+ await run(['gmail', 'search', '--', '--readonly=false'], { spawner });
988
+ expect(spawner).toHaveBeenCalledTimes(1);
989
+ });
990
+ });
991
+
992
+ // BUG-1: a positional value that starts with '-' (a Gmail negation query such
993
+ // as "-in:spam", an ID or a name the model chose) must never be parsed as a
994
+ // gog flag. Values marked with pos() go after a single `--`, after every flag,
995
+ // in their original order — the pattern upstream gogcli's own MCP tools use.
996
+ describe('run positionals', () => {
997
+ it('moves pos() values after -- at the end, keeping their order', async () => {
998
+ const spawner = makeSpawner(0, '{}');
999
+ await run(['sheets', 'get', pos('id1'), pos('-A1'), '--render=FORMULA'], { spawner });
1000
+ expect(spawner).toHaveBeenCalledWith(
1001
+ 'gog',
1002
+ ['--json', '--color=never', '--no-input', 'sheets', 'get', '--render=FORMULA', '--', 'id1', '-A1'],
1003
+ expect.any(Object),
1004
+ );
1005
+ });
1006
+
1007
+ it('passes a flag-shaped positional through as data, not a flag', async () => {
1008
+ const spawner = makeSpawner(0, '{}');
1009
+ await run(['gmail', 'search', pos('--readonly=false')], { readonly: true, spawner });
1010
+ expect(spawner).toHaveBeenCalledWith(
1011
+ 'gog',
1012
+ ['--json', '--color=never', '--no-input', '--readonly', 'gmail', 'search', '--', '--readonly=false'],
1013
+ expect.any(Object),
1014
+ );
1015
+ });
1016
+
1017
+ it('adds no -- when nothing is marked positional', async () => {
1018
+ const spawner = makeSpawner(0, '{}');
1019
+ await run(['gmail', 'labels', 'list'], { spawner });
1020
+ expect((spawner as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]![1]).not.toContain('--');
1021
+ });
1022
+
1023
+ it('treats a positional file arg as a positional', async () => {
1024
+ const spawner = makeSpawner(0, '{}');
1025
+ await run(
1026
+ ['drive', 'upload', { kind: 'file', flag: 'upload', contents: 'aGk=', encoding: 'base64', filename: 'a.txt', positional: true }, '--parent=p'],
1027
+ { spawner },
1028
+ );
1029
+ const argv = (spawner as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]![1] as string[];
1030
+ expect(argv.slice(0, 7)).toEqual(['--json', '--color=never', '--no-input', 'drive', 'upload', '--parent=p', '--']);
1031
+ expect(argv[7]).toMatch(/a\.txt$/);
1032
+ });
1033
+
1034
+ it('keeps everything after an explicit -- positional', async () => {
1035
+ const spawner = makeSpawner(0, '{}');
1036
+ await runBinary(['api', 'call', '--', 'drive', pos('v3')], { spawner });
1037
+ expect(spawner).toHaveBeenCalledWith(
1038
+ 'gog',
1039
+ ['--json', '--color=never', '--no-input', 'api', 'call', '--', 'drive', 'v3'],
1040
+ expect.any(Object),
1041
+ );
1042
+ });
1043
+ });
1044
+
1045
+ // BUG-2: binary mode buffered a whole Drive file (plus a base64 copy) with no
1046
+ // ceiling, so one multi-GB read could exhaust the server process.
1047
+ describe('runBinary maxOutputBytes', () => {
1048
+ function streamingSpawner(chunks: Buffer[]): { spawner: Spawner; kill: ReturnType<typeof vi.fn> } {
1049
+ const kill = vi.fn();
1050
+ const spawner = vi.fn(() => {
1051
+ const proc = new EventEmitter() as ReturnType<Spawner>;
1052
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
1053
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
1054
+ proc.kill = kill;
1055
+ setTimeout(() => {
1056
+ for (const c of chunks) (proc as unknown as { stdout: EventEmitter }).stdout.emit('data', c);
1057
+ proc.emit('close', 0);
1058
+ }, 0);
1059
+ return proc;
1060
+ }) as unknown as Spawner;
1061
+ return { spawner, kill };
1062
+ }
1063
+
1064
+ it('kills gog and rejects once the output passes the cap', async () => {
1065
+ const { spawner, kill } = streamingSpawner([Buffer.alloc(6), Buffer.alloc(6), Buffer.alloc(6)]);
1066
+ await expect(runBinary(['api', 'call'], { spawner, maxOutputBytes: 10 }))
1067
+ .rejects.toThrow(/more than 10 bytes/);
1068
+ expect(kill).toHaveBeenCalled();
1069
+ });
1070
+
1071
+ it('returns output at or under the cap', async () => {
1072
+ const { spawner } = streamingSpawner([Buffer.from('hello')]);
1073
+ expect(await runBinary(['api', 'call'], { spawner, maxOutputBytes: 5 })).toBe(Buffer.from('hello').toString('base64'));
1074
+ });
1075
+ });
1076
+
941
1077
  describe('run executor', () => {
942
1078
  it('uses the lazily-imported real spawn when no spawner is injected', async () => {
943
1079
  vi.mocked(mockedSpawn).mockImplementation((() => makeProc(0, '{"real":true}')) as never);
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ CONFIRM_TOKEN_TTL_DEFAULT_SECONDS,
4
+ confirmTokenTtlSeconds,
5
+ hashSendPayload,
6
+ issueConfirmToken,
7
+ resetConfirmTokenState,
8
+ sendConfirmFallbackEnabled,
9
+ verifyConfirmToken,
10
+ type ConfirmBinding,
11
+ } from '../src/send-confirm-token.js';
12
+
13
+ const ORIGINAL_ENV = { ...process.env };
14
+
15
+ const BINDING: ConfirmBinding = {
16
+ tool: 'gog_gmail_drafts_send',
17
+ account: 'me@example.com',
18
+ target: 'r123',
19
+ revision: 'msg-1',
20
+ payloadHash: hashSendPayload({ to: 'a@example.com', body: 'hello' }),
21
+ };
22
+
23
+ const NOW = 1_800_000_000_000;
24
+
25
+ beforeEach(() => {
26
+ process.env = { ...ORIGINAL_ENV };
27
+ delete process.env.GOG_SEND_CONFIRM_FALLBACK;
28
+ delete process.env.GOG_CONFIRM_SECRET;
29
+ delete process.env.GOG_CONFIRM_TTL_SECONDS;
30
+ resetConfirmTokenState();
31
+ });
32
+
33
+ afterEach(() => {
34
+ process.env = ORIGINAL_ENV;
35
+ resetConfirmTokenState();
36
+ });
37
+
38
+ describe('hashSendPayload', () => {
39
+ it('is independent of key order, so a re-read payload hashes the same', () => {
40
+ expect(hashSendPayload({ a: 1, b: { c: 2, d: [1, 2] } }))
41
+ .toBe(hashSendPayload({ b: { d: [1, 2], c: 2 }, a: 1 }));
42
+ });
43
+
44
+ it('changes when any value changes, including array order', () => {
45
+ const base = hashSendPayload({ to: ['a', 'b'], body: 'x' });
46
+ expect(hashSendPayload({ to: ['b', 'a'], body: 'x' })).not.toBe(base);
47
+ expect(hashSendPayload({ to: ['a', 'b'], body: 'x ' })).not.toBe(base);
48
+ });
49
+
50
+ it('treats an undefined field as absent', () => {
51
+ expect(hashSendPayload({ a: 1, b: undefined })).toBe(hashSendPayload({ a: 1 }));
52
+ });
53
+
54
+ it('distinguishes null from absent', () => {
55
+ expect(hashSendPayload({ a: null })).not.toBe(hashSendPayload({}));
56
+ });
57
+ });
58
+
59
+ describe('sendConfirmFallbackEnabled', () => {
60
+ it('is off unless GOG_SEND_CONFIRM_FALLBACK is exactly "token" (case-insensitive)', () => {
61
+ expect(sendConfirmFallbackEnabled()).toBe(false);
62
+ process.env.GOG_SEND_CONFIRM_FALLBACK = '1';
63
+ expect(sendConfirmFallbackEnabled()).toBe(false);
64
+ process.env.GOG_SEND_CONFIRM_FALLBACK = ' Token ';
65
+ expect(sendConfirmFallbackEnabled()).toBe(true);
66
+ });
67
+
68
+ it('treats an unresolved .mcpb placeholder as unset', () => {
69
+ process.env.GOG_SEND_CONFIRM_FALLBACK = '${user_config.send_confirm_fallback}';
70
+ expect(sendConfirmFallbackEnabled()).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe('confirmTokenTtlSeconds', () => {
75
+ it('defaults to ten minutes', () => {
76
+ expect(CONFIRM_TOKEN_TTL_DEFAULT_SECONDS).toBe(600);
77
+ expect(confirmTokenTtlSeconds()).toBe(600);
78
+ });
79
+
80
+ it('honours a positive integer GOG_CONFIRM_TTL_SECONDS', () => {
81
+ process.env.GOG_CONFIRM_TTL_SECONDS = '30';
82
+ expect(confirmTokenTtlSeconds()).toBe(30);
83
+ });
84
+
85
+ it.each(['0', '-5', 'abc', '1.5', ''])('falls back to the default for %j', (value) => {
86
+ process.env.GOG_CONFIRM_TTL_SECONDS = value;
87
+ expect(confirmTokenTtlSeconds()).toBe(600);
88
+ });
89
+ });
90
+
91
+ describe('issueConfirmToken / verifyConfirmToken', () => {
92
+ it('accepts a fresh token for the exact binding it was issued for', () => {
93
+ const { token, expiresAt } = issueConfirmToken(BINDING, NOW);
94
+ expect(expiresAt).toBe(new Date(NOW + 600_000).toISOString());
95
+ expect(verifyConfirmToken(token, BINDING, NOW + 1_000)).toEqual({ ok: true });
96
+ });
97
+
98
+ it('is single-use: the second presentation is TOKEN_REUSED', () => {
99
+ const { token } = issueConfirmToken(BINDING, NOW);
100
+ expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
101
+ expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_REUSED' });
102
+ });
103
+
104
+ it('expires after the TTL', () => {
105
+ const { token } = issueConfirmToken(BINDING, NOW);
106
+ expect(verifyConfirmToken(token, BINDING, NOW + 600_001)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
107
+ });
108
+
109
+ it('uses GOG_CONFIRM_TTL_SECONDS at issue time', () => {
110
+ process.env.GOG_CONFIRM_TTL_SECONDS = '5';
111
+ const { token } = issueConfirmToken(BINDING, NOW);
112
+ expect(verifyConfirmToken(token, BINDING, NOW + 5_001)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
113
+ });
114
+
115
+ it('does not consume a token that failed verification', () => {
116
+ const { token } = issueConfirmToken(BINDING, NOW);
117
+ const changed = { ...BINDING, payloadHash: hashSendPayload({ to: 'b@example.com' }) };
118
+ expect(verifyConfirmToken(token, changed, NOW)).toMatchObject({ ok: false, error: 'DRAFT_CHANGED' });
119
+ expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
120
+ });
121
+
122
+ it('reports DRAFT_CHANGED with reason payload-changed when the payload hash differs', () => {
123
+ const { token } = issueConfirmToken(BINDING, NOW);
124
+ const changed = { ...BINDING, payloadHash: hashSendPayload({ to: 'a@example.com', body: 'HELLO' }) };
125
+ expect(verifyConfirmToken(token, changed, NOW)).toEqual({ ok: false, error: 'DRAFT_CHANGED', reason: 'payload-changed' });
126
+ });
127
+
128
+ it('reports DRAFT_CHANGED with reason message-id-rotated when the revision rotated', () => {
129
+ const { token } = issueConfirmToken(BINDING, NOW);
130
+ expect(verifyConfirmToken(token, { ...BINDING, revision: 'msg-2' }, NOW))
131
+ .toEqual({ ok: false, error: 'DRAFT_CHANGED', reason: 'message-id-rotated' });
132
+ });
133
+
134
+ it.each([
135
+ ['tool', { tool: 'gog_gmail_send' }],
136
+ ['account', { account: 'other@example.com' }],
137
+ ['draft/message', { target: 'r999' }],
138
+ ] as const)('never accepts a token issued for a different %s', (_label, override) => {
139
+ const { token } = issueConfirmToken(BINDING, NOW);
140
+ expect(verifyConfirmToken(token, { ...BINDING, ...override }, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
141
+ });
142
+
143
+ it('rejects a tampered claim as TOKEN_INVALID', () => {
144
+ const { token } = issueConfirmToken(BINDING, NOW);
145
+ const [prefix, claims, sig] = token.split('.');
146
+ const decoded = JSON.parse(Buffer.from(claims!, 'base64url').toString('utf8'));
147
+ decoded.exp += 3_600_000;
148
+ const forged = `${prefix}.${Buffer.from(JSON.stringify(decoded)).toString('base64url')}.${sig}`;
149
+ expect(verifyConfirmToken(forged, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
150
+ });
151
+
152
+ it('rejects a tampered signature as TOKEN_INVALID', () => {
153
+ const { token } = issueConfirmToken(BINDING, NOW);
154
+ const flipped = token.slice(0, -2) + (token.endsWith('AA') ? 'BB' : 'AA');
155
+ expect(verifyConfirmToken(flipped, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
156
+ });
157
+
158
+ it.each(['', 'garbage', 'gct1.a.b', 'x.y.z', 'gct1..', 'gct1.a.b.c'])('rejects malformed token %j as TOKEN_INVALID', (token) => {
159
+ expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
160
+ });
161
+
162
+ it('rejects a correctly signed token whose claims are not JSON', async () => {
163
+ const { createHmac } = await import('node:crypto');
164
+ process.env.GOG_CONFIRM_SECRET = 'a-shared-secret';
165
+ resetConfirmTokenState();
166
+ const claims = Buffer.from('not json').toString('base64url');
167
+ const sig = createHmac('sha256', 'a-shared-secret').update(`gct1.${claims}`).digest('base64url');
168
+ expect(verifyConfirmToken(`gct1.${claims}.${sig}`, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
169
+ });
170
+
171
+ it('rejects a token signed by a previous process (per-process secret)', () => {
172
+ const { token } = issueConfirmToken(BINDING, NOW);
173
+ resetConfirmTokenState();
174
+ expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
175
+ });
176
+
177
+ it('with GOG_CONFIRM_SECRET set, a token survives a restart', () => {
178
+ process.env.GOG_CONFIRM_SECRET = 'a-shared-secret';
179
+ resetConfirmTokenState();
180
+ const { token } = issueConfirmToken(BINDING, NOW);
181
+ resetConfirmTokenState();
182
+ expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
183
+ });
184
+
185
+ it('binds a revision-less subject too', () => {
186
+ const noRevision = { ...BINDING, revision: undefined };
187
+ const { token } = issueConfirmToken(noRevision, NOW);
188
+ expect(verifyConfirmToken(token, noRevision, NOW)).toEqual({ ok: true });
189
+ });
190
+
191
+ it('forgets used tokens once they would have expired anyway', () => {
192
+ const first = issueConfirmToken(BINDING, NOW);
193
+ expect(verifyConfirmToken(first.token, BINDING, NOW)).toEqual({ ok: true });
194
+ // A later verification prunes the spent entry; the old token is then
195
+ // rejected as expired rather than remembered forever.
196
+ const second = issueConfirmToken(BINDING, NOW + 700_000);
197
+ expect(verifyConfirmToken(second.token, BINDING, NOW + 700_000)).toEqual({ ok: true });
198
+ expect(verifyConfirmToken(first.token, BINDING, NOW + 700_000)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
199
+ });
200
+ });
@@ -1,7 +1,8 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { registerApiTools } from '../../src/tools/api.js';
3
3
  import * as runner from '../../src/runner.js';
4
4
  import { createTestHarness } from '@chrischall/mcp-utils/test';
5
+ import { pos } from '../../src/argv.js';
5
6
 
6
7
  vi.mock('../../src/runner.js');
7
8
 
@@ -37,7 +38,7 @@ describe('gog_api_describe', () => {
37
38
  vi.mocked(runner.run).mockResolvedValue('{}');
38
39
  const harness = await setupHandlers();
39
40
  await harness.callTool('gog_api_describe', { api: 'drive', version: 'v3' });
40
- expect(runner.run).toHaveBeenCalledWith(['api', 'describe', 'drive', 'v3'], { account: undefined });
41
+ expect(runner.run).toHaveBeenCalledWith(['api', 'describe', pos('drive'), pos('v3')], { account: undefined });
41
42
  });
42
43
 
43
44
  it('describes a single method when method is provided', async () => {
@@ -45,7 +46,7 @@ describe('gog_api_describe', () => {
45
46
  const harness = await setupHandlers();
46
47
  await harness.callTool('gog_api_describe', { api: 'drive', version: 'v3', method: 'files.list' });
47
48
  expect(runner.run).toHaveBeenCalledWith(
48
- ['api', 'describe', 'drive', 'v3', 'files.list'],
49
+ ['api', 'describe', pos('drive'), pos('v3'), pos('files.list')],
49
50
  { account: undefined },
50
51
  );
51
52
  });
@@ -64,8 +65,8 @@ describe('gog_api_call', () => {
64
65
  const harness = await setupHandlers();
65
66
  await harness.callTool('gog_api_call', { api: 'drive', version: 'v3', method: 'files.list', params: '{"q":"x"}' });
66
67
  expect(runner.run).toHaveBeenCalledWith(
67
- ['api', 'call', 'drive', 'v3', 'files.list', '--params={"q":"x"}'],
68
- { account: undefined },
68
+ ['api', 'call', pos('drive'), pos('v3'), pos('files.list'), '--params={"q":"x"}'],
69
+ { account: undefined, gmailNoSend: true },
69
70
  );
70
71
  });
71
72
 
@@ -79,11 +80,11 @@ describe('gog_api_call', () => {
79
80
  });
80
81
  expect(runner.run).toHaveBeenCalledWith(
81
82
  [
82
- 'api', 'call', 'drive', 'v3', 'files.create',
83
+ 'api', 'call', pos('drive'), pos('v3'), pos('files.create'),
83
84
  '--params={"fields":"id"}', '--body={"name":"f"}',
84
85
  '--scope=https://www.googleapis.com/auth/drive', '--allow-write', '--dry-run', '--force',
85
86
  ],
86
- { account: 'a@b.com' },
87
+ { account: 'a@b.com', gmailNoSend: true },
87
88
  );
88
89
  });
89
90
 
@@ -94,7 +95,7 @@ describe('gog_api_call', () => {
94
95
  api: 'drive', version: 'v3', method: 'files.create', allowWrite: true,
95
96
  });
96
97
  const passedArgs = vi.mocked(runner.run).mock.calls[0][0];
97
- expect(passedArgs).toEqual(['api', 'call', 'drive', 'v3', 'files.create', '--allow-write', '--force']);
98
+ expect(passedArgs).toEqual(['api', 'call', pos('drive'), pos('v3'), pos('files.create'), '--allow-write', '--force']);
98
99
  expect(passedArgs[passedArgs.length - 1]).toBe('--force');
99
100
  });
100
101
 
@@ -104,4 +105,69 @@ describe('gog_api_call', () => {
104
105
  const result = await harness.callTool('gog_api_call', { api: 'drive', version: 'v3', method: 'files.list' });
105
106
  expect(result.content[0].text).toBe('Error: Call failed');
106
107
  });
108
+
109
+ // SEC-2: allowWrite is a model-set boolean, so it cannot stand in for the
110
+ // user's confirmation of a send. Sends are refused here (and --gmail-no-send
111
+ // is pinned on as a backstop); so is anything that routes future mail away.
112
+ it.each([
113
+ ['gmail', 'users.messages.send'],
114
+ ['Gmail', 'users.drafts.send'],
115
+ ['gmail', 'users.settings.forwardingAddresses.create'],
116
+ ['gmail', 'users.settings.updateAutoForwarding'],
117
+ ['gmail', 'users.settings.filters.create'],
118
+ ['gmail', 'users.settings.delegates.create'],
119
+ ])('refuses %s %s', async (api, method) => {
120
+ const harness = await setupHandlers();
121
+ const result = await harness.callTool('gog_api_call', { api, version: 'v1', method, allowWrite: true });
122
+ expect(result.isError).toBe(true);
123
+ expect(result.content[0].text).toMatch(/not available through gog_api_call/);
124
+ expect(runner.run).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it('still allows other gmail methods', async () => {
128
+ vi.mocked(runner.run).mockResolvedValue('{}');
129
+ const harness = await setupHandlers();
130
+ const result = await harness.callTool('gog_api_call', { api: 'gmail', version: 'v1', method: 'users.labels.list' });
131
+ expect(result.isError).toBeFalsy();
132
+ });
133
+
134
+ // SEC-1: the positional api/version/method are model-supplied too.
135
+ it.each([
136
+ [{ api: '--readonly=false', version: 'v3', method: 'files.list' }],
137
+ [{ api: 'drive', version: '--', method: 'files.list' }],
138
+ [{ api: 'drive', version: 'v3', method: '--disable-commands=' }],
139
+ ])('refuses a safety-flag override in %j', async (input) => {
140
+ const harness = await setupHandlers();
141
+ const result = await harness.callTool('gog_api_call', input);
142
+ expect(result.isError).toBe(true);
143
+ expect(result.content[0].text).toMatch(/not allowed/);
144
+ expect(runner.run).not.toHaveBeenCalled();
145
+ });
146
+ });
147
+
148
+ // SEC-3/SEC-4: gog reads `--body=@path` from the host, so an unconfined body
149
+ // is a read primitive (gog's credentials.json is itself valid JSON).
150
+ describe('gog_api_call @file confinement', () => {
151
+ let prevRoots: string | undefined;
152
+ beforeEach(() => { prevRoots = process.env.GOG_FILE_ROOTS; process.env.GOG_FILE_ROOTS = '/nonexistent-gog-file-root'; });
153
+ afterEach(() => { process.env.GOG_FILE_ROOTS = prevRoots; });
154
+
155
+ it.each([
156
+ [{ body: '@/Users/x/Library/Application Support/gogcli/credentials.json' }, /body/],
157
+ [{ params: '@/etc/passwd' }, /params/],
158
+ ])('refuses %j outside GOG_FILE_ROOTS', async (extra, param) => {
159
+ const harness = await createTestHarness(registerApiTools);
160
+ const result = await harness.callTool('gog_api_call', { api: 'drive', version: 'v3', method: 'files.create', allowWrite: true, ...extra });
161
+ expect(result.isError).toBe(true);
162
+ expect(result.content[0].text).toMatch(/outside the directories/);
163
+ expect(result.content[0].text).toMatch(param);
164
+ expect(runner.run).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it('passes an inline JSON body through', async () => {
168
+ vi.mocked(runner.run).mockResolvedValue('{}');
169
+ const harness = await createTestHarness(registerApiTools);
170
+ await harness.callTool('gog_api_call', { api: 'drive', version: 'v3', method: 'files.list', body: '{"a":1}' });
171
+ expect(runner.run).toHaveBeenCalled();
172
+ });
107
173
  });
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerAppScriptTools } from '../../src/tools/appscript.js';
3
3
  import * as runner from '../../src/runner.js';
4
4
  import { createTestHarness } from '@chrischall/mcp-utils/test';
5
+ import { pos } from '../../src/argv.js';
5
6
 
6
7
  vi.mock('../../src/runner.js');
7
8
 
@@ -14,7 +15,7 @@ describe('gog_appscript_get', () => {
14
15
  vi.mocked(runner.run).mockResolvedValue('{}');
15
16
  const harness = await setupHandlers();
16
17
  await harness.callTool('gog_appscript_get', { scriptId: 'S1' });
17
- expect(runner.run).toHaveBeenCalledWith(['appscript', 'get', 'S1'], { account: undefined });
18
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'get', pos('S1')], { account: undefined });
18
19
  });
19
20
 
20
21
  it('returns error text on failure', async () => {
@@ -30,7 +31,7 @@ describe('gog_appscript_content', () => {
30
31
  vi.mocked(runner.run).mockResolvedValue('{}');
31
32
  const harness = await setupHandlers();
32
33
  await harness.callTool('gog_appscript_content', { scriptId: 'S1', account: 'me@x.com' });
33
- expect(runner.run).toHaveBeenCalledWith(['appscript', 'content', 'S1'], { account: 'me@x.com' });
34
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'content', pos('S1')], { account: 'me@x.com' });
34
35
  });
35
36
  });
36
37
 
@@ -39,7 +40,7 @@ describe('gog_appscript_pull', () => {
39
40
  vi.mocked(runner.run).mockResolvedValue('{}');
40
41
  const harness = await setupHandlers();
41
42
  await harness.callTool('gog_appscript_pull', { scriptId: 'S1', dir: '/tmp/proj' });
42
- expect(runner.run).toHaveBeenCalledWith(['appscript', 'pull', 'S1', '/tmp/proj'], { account: undefined });
43
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'pull', pos('S1'), pos('/tmp/proj')], { account: undefined });
43
44
  });
44
45
 
45
46
  it('passes --overwrite', async () => {
@@ -47,7 +48,7 @@ describe('gog_appscript_pull', () => {
47
48
  const harness = await setupHandlers();
48
49
  await harness.callTool('gog_appscript_pull', { scriptId: 'S1', dir: '/tmp/proj', overwrite: true });
49
50
  expect(runner.run).toHaveBeenCalledWith(
50
- ['appscript', 'pull', 'S1', '/tmp/proj', '--overwrite'],
51
+ ['appscript', 'pull', pos('S1'), pos('/tmp/proj'), '--overwrite'],
51
52
  { account: undefined },
52
53
  );
53
54
  });
@@ -95,7 +96,7 @@ describe('gog_appscript_deployments', () => {
95
96
  const harness = await setupHandlers();
96
97
  await harness.callTool('gog_appscript_deployments', { scriptId: 'S1', max: 10, pageToken: 'tok' });
97
98
  expect(runner.run).toHaveBeenCalledWith(
98
- ['appscript', 'deployments', 'S1', '--max=10', '--page=tok'],
99
+ ['appscript', 'deployments', pos('S1'), '--max=10', '--page=tok'],
99
100
  { account: undefined },
100
101
  );
101
102
  });
@@ -106,7 +107,7 @@ describe('gog_appscript_versions', () => {
106
107
  vi.mocked(runner.run).mockResolvedValue('{}');
107
108
  const harness = await setupHandlers();
108
109
  await harness.callTool('gog_appscript_versions', { scriptId: 'S1', all: true });
109
- expect(runner.run).toHaveBeenCalledWith(['appscript', 'versions', 'S1', '--all'], { account: undefined });
110
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'versions', pos('S1'), '--all'], { account: undefined });
110
111
  });
111
112
  });
112
113
 
@@ -115,7 +116,7 @@ describe('gog_appscript_run_function', () => {
115
116
  vi.mocked(runner.run).mockResolvedValue('{}');
116
117
  const harness = await setupHandlers();
117
118
  await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork' });
118
- expect(runner.run).toHaveBeenCalledWith(['appscript', 'run', 'S1', 'doWork'], { account: undefined });
119
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'run', pos('S1'), pos('doWork')], { account: undefined });
119
120
  });
120
121
 
121
122
  it('passes params and --dev-mode', async () => {
@@ -125,7 +126,7 @@ describe('gog_appscript_run_function', () => {
125
126
  scriptId: 'S1', functionName: 'doWork', params: '["a",1]', devMode: true,
126
127
  });
127
128
  expect(runner.run).toHaveBeenCalledWith(
128
- ['appscript', 'run', 'S1', 'doWork', '--params=["a",1]', '--dev-mode'],
129
+ ['appscript', 'run', pos('S1'), pos('doWork'), '--params=["a",1]', '--dev-mode'],
129
130
  { account: undefined },
130
131
  );
131
132
  });
@@ -157,3 +158,28 @@ describe('gog_appscript_run', () => {
157
158
  expect(runner.run).toHaveBeenCalledWith(['appscript', 'get', 'S1'], { account: undefined });
158
159
  });
159
160
  });
161
+
162
+ // SEC-3/SEC-4: every model-supplied server path must resolve inside an
163
+ // operator-configured root (GOG_FILE_ROOTS). The suite runs with '/' so the
164
+ // arg-shape tests can use any path; these narrow it.
165
+ async function withFileRoots<T>(roots: string, fn: () => Promise<T>): Promise<T> {
166
+ const prev = process.env.GOG_FILE_ROOTS;
167
+ process.env.GOG_FILE_ROOTS = roots;
168
+ try {
169
+ return await fn();
170
+ } finally {
171
+ process.env.GOG_FILE_ROOTS = prev;
172
+ }
173
+ }
174
+
175
+ describe('gog_appscript_pull — server paths', () => {
176
+ it('refuses a dir outside GOG_FILE_ROOTS', async () => {
177
+ const harness = await setupHandlers();
178
+ const result = await withFileRoots('/srv/gog-files', () => harness.callTool('gog_appscript_pull', {
179
+ scriptId: 'S1', dir: '/Users/me/Library/LaunchAgents',
180
+ }));
181
+ expect(result.isError).toBe(true);
182
+ expect(result.content[0].text).toContain('dir "/Users/me/Library/LaunchAgents" is outside');
183
+ expect(runner.run).not.toHaveBeenCalled();
184
+ });
185
+ });