gogcli-mcp 4.2.4 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +714 -216
- package/dist/lib.js +751 -217
- package/manifest.json +9 -2
- package/mint.yaml +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/arg-guard.ts +68 -0
- package/src/argv.ts +27 -0
- package/src/attachment-root.ts +111 -0
- package/src/attachments.ts +1 -0
- package/src/blob-upload.ts +4 -2
- package/src/file-roots.ts +66 -0
- package/src/gmail-dispatch-guard.ts +56 -16
- package/src/gmail-results.ts +21 -2
- package/src/lib.ts +6 -0
- package/src/run-path-guard.ts +118 -0
- package/src/runner.ts +87 -19
- package/src/tools/api.ts +39 -7
- package/src/tools/appscript.ts +12 -8
- package/src/tools/auth.ts +13 -3
- package/src/tools/calendar.ts +10 -8
- package/src/tools/chat.ts +16 -13
- package/src/tools/classroom.ts +27 -25
- package/src/tools/contacts.ts +5 -3
- package/src/tools/docs.ts +8 -6
- package/src/tools/drive.ts +42 -18
- package/src/tools/gmail.ts +71 -11
- package/src/tools/sheets.ts +10 -8
- package/src/tools/slides.ts +14 -9
- package/src/tools/tasks.ts +7 -5
- package/src/tools/utils.ts +52 -6
- package/tests/arg-guard.test.ts +80 -0
- package/tests/attachment-root.test.ts +130 -0
- package/tests/attachments.test.ts +8 -0
- package/tests/blob-upload.test.ts +4 -3
- package/tests/file-roots.test.ts +101 -0
- package/tests/gmail-dispatch-guard.test.ts +46 -0
- package/tests/gmail-results.test.ts +35 -1
- package/tests/run-path-guard.test.ts +142 -0
- package/tests/runner.test.ts +136 -0
- package/tests/tools/api.test.ts +74 -8
- package/tests/tools/appscript.test.ts +34 -8
- package/tests/tools/auth.test.ts +44 -17
- package/tests/tools/calendar.test.ts +29 -27
- package/tests/tools/chat.test.ts +46 -20
- package/tests/tools/classroom.test.ts +39 -38
- package/tests/tools/contacts.test.ts +3 -2
- package/tests/tools/docs.test.ts +44 -15
- package/tests/tools/drive.test.ts +106 -19
- package/tests/tools/gmail.test.ts +227 -29
- package/tests/tools/run-tool-examples.test.ts +69 -0
- package/tests/tools/sheets.test.ts +16 -15
- package/tests/tools/slides.test.ts +47 -11
- package/tests/tools/tasks.test.ts +7 -6
- package/tests/tools/utils.test.ts +32 -31
- package/vitest.config.ts +5 -0
package/tests/runner.test.ts
CHANGED
|
@@ -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);
|
package/tests/tools/api.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/tests/tools/auth.test.ts
CHANGED
|
@@ -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('
|
|
385
|
+
vi.mocked(runner.run).mockResolvedValue('accounts');
|
|
385
386
|
const harness = await setupHandlers();
|
|
386
|
-
await harness.callTool('gog_auth_run', { subcommand: '
|
|
387
|
-
expect(runner.run).toHaveBeenCalledWith(['auth', '
|
|
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 () => {
|