gogcli-mcp 4.2.5 → 4.3.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 (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +714 -216
  4. package/dist/lib.js +750 -216
  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/file-roots.ts +66 -0
  14. package/src/gmail-dispatch-guard.ts +56 -16
  15. package/src/gmail-results.ts +21 -2
  16. package/src/lib.ts +6 -0
  17. package/src/run-path-guard.ts +118 -0
  18. package/src/runner.ts +86 -18
  19. package/src/tools/api.ts +39 -7
  20. package/src/tools/appscript.ts +12 -8
  21. package/src/tools/auth.ts +13 -3
  22. package/src/tools/calendar.ts +10 -8
  23. package/src/tools/chat.ts +16 -13
  24. package/src/tools/classroom.ts +27 -25
  25. package/src/tools/contacts.ts +5 -3
  26. package/src/tools/docs.ts +8 -6
  27. package/src/tools/drive.ts +42 -18
  28. package/src/tools/gmail.ts +71 -11
  29. package/src/tools/sheets.ts +10 -8
  30. package/src/tools/slides.ts +14 -9
  31. package/src/tools/tasks.ts +7 -5
  32. package/src/tools/utils.ts +52 -6
  33. package/tests/arg-guard.test.ts +80 -0
  34. package/tests/attachment-root.test.ts +130 -0
  35. package/tests/attachments.test.ts +8 -0
  36. package/tests/blob-upload.test.ts +4 -3
  37. package/tests/file-roots.test.ts +101 -0
  38. package/tests/gmail-dispatch-guard.test.ts +46 -0
  39. package/tests/gmail-results.test.ts +35 -1
  40. package/tests/run-path-guard.test.ts +142 -0
  41. package/tests/runner.test.ts +136 -0
  42. package/tests/tools/api.test.ts +74 -8
  43. package/tests/tools/appscript.test.ts +34 -8
  44. package/tests/tools/auth.test.ts +44 -17
  45. package/tests/tools/calendar.test.ts +29 -27
  46. package/tests/tools/chat.test.ts +46 -20
  47. package/tests/tools/classroom.test.ts +39 -38
  48. package/tests/tools/contacts.test.ts +3 -2
  49. package/tests/tools/docs.test.ts +44 -15
  50. package/tests/tools/drive.test.ts +106 -19
  51. package/tests/tools/gmail.test.ts +227 -29
  52. package/tests/tools/run-tool-examples.test.ts +69 -0
  53. package/tests/tools/sheets.test.ts +16 -15
  54. package/tests/tools/slides.test.ts +47 -11
  55. package/tests/tools/tasks.test.ts +7 -6
  56. package/tests/tools/utils.test.ts +32 -31
  57. 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);
@@ -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
+ });
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerAuthTools, authToolsFor } from '../../src/tools/auth.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
 
@@ -108,7 +109,7 @@ describe('gog_auth_add', () => {
108
109
  const harness = await setupHandlers();
109
110
  const result = await harness.callTool('gog_auth_add', { email: 'user@gmail.com' });
110
111
  expect(runner.run).toHaveBeenCalledWith(
111
- ['auth', 'add', 'user@gmail.com', '--services', 'all'],
112
+ ['auth', 'add', pos('user@gmail.com'), '--services', 'all'],
112
113
  { interactive: true, timeout: 300_000 },
113
114
  );
114
115
  expect(result.content[0].text).toBe('Authorization successful for user@gmail.com');
@@ -119,7 +120,7 @@ describe('gog_auth_add', () => {
119
120
  const harness = await setupHandlers();
120
121
  await harness.callTool('gog_auth_add', { email: 'user@gmail.com', services: 'sheets,gmail' });
121
122
  expect(runner.run).toHaveBeenCalledWith(
122
- ['auth', 'add', 'user@gmail.com', '--services', 'sheets,gmail'],
123
+ ['auth', 'add', pos('user@gmail.com'), '--services', 'sheets,gmail'],
123
124
  { interactive: true, timeout: 300_000 },
124
125
  );
125
126
  });
@@ -137,7 +138,7 @@ describe('gog_auth_add', () => {
137
138
  extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
138
139
  });
139
140
  expect(runner.run).toHaveBeenCalledWith(
140
- ['auth', 'add', 'user@gmail.com', '--services', 'sheets',
141
+ ['auth', 'add', pos('user@gmail.com'), '--services', 'sheets',
141
142
  '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly', '--force-consent'],
142
143
  { interactive: true, timeout: 300_000 },
143
144
  );
@@ -148,7 +149,7 @@ describe('gog_auth_add', () => {
148
149
  const harness = await setupHandlers();
149
150
  await harness.callTool('gog_auth_add', { email: 'user@gmail.com' });
150
151
  expect(runner.run).toHaveBeenCalledWith(
151
- ['auth', 'add', 'user@gmail.com', '--services', 'all'],
152
+ ['auth', 'add', pos('user@gmail.com'), '--services', 'all'],
152
153
  { interactive: true, timeout: 300_000 },
153
154
  );
154
155
  });
@@ -229,7 +230,7 @@ describe('gog_auth_add_url', () => {
229
230
  const harness = await setupHandlers();
230
231
  const result = await harness.callTool('gog_auth_add_url', { email: 'user@gmail.com' });
231
232
  expect(runner.run).toHaveBeenCalledWith(
232
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '1', '--services', 'all', '--force-consent'],
233
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '1', '--services', 'all', '--force-consent'],
233
234
  { redactMode: 'tokens' },
234
235
  );
235
236
  expect(result.content[0].text).toContain('accounts.google.com');
@@ -240,7 +241,7 @@ describe('gog_auth_add_url', () => {
240
241
  const harness = await setupHandlers();
241
242
  await harness.callTool('gog_auth_add_url', { email: 'user@gmail.com', services: 'gmail,drive' });
242
243
  expect(runner.run).toHaveBeenCalledWith(
243
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '1', '--services', 'gmail,drive', '--force-consent'],
244
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '1', '--services', 'gmail,drive', '--force-consent'],
244
245
  { redactMode: 'tokens' },
245
246
  );
246
247
  });
@@ -254,7 +255,7 @@ describe('gog_auth_add_url', () => {
254
255
  extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
255
256
  });
256
257
  expect(runner.run).toHaveBeenCalledWith(
257
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '1', '--services', 'sheets', '--force-consent',
258
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '1', '--services', 'sheets', '--force-consent',
258
259
  '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly'],
259
260
  { redactMode: 'tokens' },
260
261
  );
@@ -277,7 +278,7 @@ describe('gog_auth_add_complete', () => {
277
278
  redirectUrl: 'http://127.0.0.1:59436/oauth2/callback?code=abc&state=xyz',
278
279
  });
279
280
  expect(runner.run).toHaveBeenCalledWith(
280
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '2', '--auth-url',
281
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '2', '--auth-url',
281
282
  'http://127.0.0.1:59436/oauth2/callback?code=abc&state=xyz', '--services', 'all', '--force-consent'],
282
283
  );
283
284
  expect(result.content[0].text).toContain('stored');
@@ -292,7 +293,7 @@ describe('gog_auth_add_complete', () => {
292
293
  services: 'gmail,drive',
293
294
  });
294
295
  expect(runner.run).toHaveBeenCalledWith(
295
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '2', '--auth-url',
296
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '2', '--auth-url',
296
297
  'http://127.0.0.1/cb?code=c&state=s', '--services', 'gmail,drive', '--force-consent'],
297
298
  );
298
299
  });
@@ -307,7 +308,7 @@ describe('gog_auth_add_complete', () => {
307
308
  extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
308
309
  });
309
310
  expect(runner.run).toHaveBeenCalledWith(
310
- ['auth', 'add', 'user@gmail.com', '--remote', '--step', '2', '--auth-url',
311
+ ['auth', 'add', pos('user@gmail.com'), '--remote', '--step', '2', '--auth-url',
311
312
  'http://127.0.0.1/cb?code=c&state=s', '--services', 'sheets', '--force-consent',
312
313
  '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly'],
313
314
  );
@@ -332,7 +333,7 @@ describe('authToolsFor (least-privilege default services)', () => {
332
333
  const harness = await gmailHarness();
333
334
  await harness.callTool('gog_auth_add_url', { email: 'u@x.com' });
334
335
  expect(runner.run).toHaveBeenCalledWith(
335
- ['auth', 'add', 'u@x.com', '--remote', '--step', '1', '--services', 'gmail', '--force-consent'],
336
+ ['auth', 'add', pos('u@x.com'), '--remote', '--step', '1', '--services', 'gmail', '--force-consent'],
336
337
  { redactMode: 'tokens' },
337
338
  );
338
339
  });
@@ -342,7 +343,7 @@ describe('authToolsFor (least-privilege default services)', () => {
342
343
  const harness = await gmailHarness();
343
344
  await harness.callTool('gog_auth_add', { email: 'u@x.com' });
344
345
  expect(runner.run).toHaveBeenCalledWith(
345
- ['auth', 'add', 'u@x.com', '--services', 'gmail'],
346
+ ['auth', 'add', pos('u@x.com'), '--services', 'gmail'],
346
347
  { interactive: true, timeout: 300_000 },
347
348
  );
348
349
  });
@@ -355,7 +356,7 @@ describe('authToolsFor (least-privilege default services)', () => {
355
356
  redirectUrl: 'http://127.0.0.1/cb?code=c&state=s',
356
357
  });
357
358
  expect(runner.run).toHaveBeenCalledWith(
358
- ['auth', 'add', 'u@x.com', '--remote', '--step', '2', '--auth-url',
359
+ ['auth', 'add', pos('u@x.com'), '--remote', '--step', '2', '--auth-url',
359
360
  'http://127.0.0.1/cb?code=c&state=s', '--services', 'gmail', '--force-consent'],
360
361
  );
361
362
  });
@@ -365,7 +366,7 @@ describe('authToolsFor (least-privilege default services)', () => {
365
366
  const harness = await gmailHarness();
366
367
  await harness.callTool('gog_auth_add_url', { email: 'u@x.com', services: 'gmail,drive' });
367
368
  expect(runner.run).toHaveBeenCalledWith(
368
- ['auth', 'add', 'u@x.com', '--remote', '--step', '1', '--services', 'gmail,drive', '--force-consent'],
369
+ ['auth', 'add', pos('u@x.com'), '--remote', '--step', '1', '--services', 'gmail,drive', '--force-consent'],
369
370
  { redactMode: 'tokens' },
370
371
  );
371
372
  });
@@ -381,10 +382,36 @@ describe('gog_auth_run', () => {
381
382
  });
382
383
 
383
384
  it('works with empty args array', async () => {
384
- vi.mocked(runner.run).mockResolvedValue('token info');
385
+ vi.mocked(runner.run).mockResolvedValue('accounts');
385
386
  const harness = await setupHandlers();
386
- await harness.callTool('gog_auth_run', { subcommand: 'tokens', args: [] });
387
- expect(runner.run).toHaveBeenCalledWith(['auth', 'tokens'], {});
387
+ await harness.callTool('gog_auth_run', { subcommand: 'list', args: [] });
388
+ expect(runner.run).toHaveBeenCalledWith(['auth', 'list'], {});
389
+ });
390
+
391
+ it.each(['list', 'status', 'services', 'remove', 'alias'])('allows %s', async (subcommand) => {
392
+ vi.mocked(runner.run).mockResolvedValue('ok');
393
+ const harness = await setupHandlers();
394
+ const result = await harness.callTool('gog_auth_run', { subcommand, args: [] });
395
+ expect(result.isError).toBeFalsy();
396
+ expect(runner.run).toHaveBeenCalledWith(['auth', subcommand], {});
397
+ });
398
+
399
+ // SEC-3: `auth tokens export --out <path>` writes a long-lived refresh token
400
+ // to disk, where an attach path can read it back out. Credentials and the
401
+ // keyring are the same class. Only the account-management subset is exposed.
402
+ it.each([
403
+ ['tokens', ['export', 'me@example.com', '--out=/tmp/t.json']],
404
+ ['tokens', ['import', '/tmp/t.json']],
405
+ ['credentials', ['list']],
406
+ ['keyring', []],
407
+ ['add', ['me@example.com']],
408
+ ['service-account', ['set']],
409
+ ])('refuses auth %s %j', async (subcommand, args) => {
410
+ const harness = await setupHandlers();
411
+ const result = await harness.callTool('gog_auth_run', { subcommand, args });
412
+ expect(result.isError).toBe(true);
413
+ expect(result.content[0].text).toMatch(/not available through gog_auth_run/);
414
+ expect(runner.run).not.toHaveBeenCalled();
388
415
  });
389
416
 
390
417
  it('returns error text on failure', async () => {