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
@@ -5,6 +5,8 @@ import { run } from '../runner.js';
5
5
  import type { GogArg } from '../runner.js';
6
6
  import { normalizeTimestamps } from '../timestamps.js';
7
7
  import { stripConsumedPageToken } from '../pagination.js';
8
+ import { assertSafeForwardedArgs, assertSafeSubcommand } from '../arg-guard.js';
9
+ import { assertRunPathsConfined, positionalPathTool } from '../run-path-guard.js';
8
10
 
9
11
  // Byte size at or below which a payload stays on the plain inline flag.
10
12
  //
@@ -110,7 +112,7 @@ export const paginationParams = {
110
112
  // Append pagination flags to an argv array. Mirrors the shape of
111
113
  // paginationParams above. Use together to keep call sites concise.
112
114
  export function pushPaginationFlags(
113
- args: string[],
115
+ args: GogArg[],
114
116
  p: { max?: number; pageToken?: string; page?: string; all?: boolean },
115
117
  ): void {
116
118
  if (p.max !== undefined) args.push(`--max=${p.max}`);
@@ -122,6 +124,14 @@ export function pushPaginationFlags(
122
124
  // Register a `gog_<service>_run` escape-hatch tool. 11 services currently
123
125
  // register an identical-shape tool; this factory keeps them in lockstep.
124
126
  // Pass `omitAccount: true` only for auth, which doesn't take --account.
127
+ //
128
+ // THE ARGS ARE MODEL-SUPPLIED AND FORWARDED VERBATIM, so every call is vetted
129
+ // before gog sees it (audit SEC-1): a safety-control override
130
+ // (`--readonly=false`, `--disable-commands=`, `--account=…`, a bare `--`, …) is
131
+ // refused rather than forwarded, and the subcommand must be a plain word.
132
+ // `allowedSubcommands` narrows a service to a named subset (auth: never
133
+ // `tokens export`), `vet` lets a service refuse specific shapes, and
134
+ // `gmailNoSend` pins gog's own `--gmail-no-send` on.
125
135
  export function registerRunTool(
126
136
  server: McpServer,
127
137
  options: {
@@ -130,11 +140,33 @@ export function registerRunTool(
130
140
  omitAccount?: boolean;
131
141
  /** Extra sentence appended to the description (used by auth to point to gog_auth_add). */
132
142
  note?: string;
143
+ /** When set, only these subcommands may run; everything else is refused. */
144
+ allowedSubcommands?: readonly string[];
145
+ /** Service-specific refusal: return a reason to refuse, or undefined to allow. */
146
+ vet?: (subcommand: string, args: readonly string[]) => string | undefined;
147
+ /** Inject gog's --gmail-no-send, so no send can be smuggled through this tool. */
148
+ gmailNoSend?: boolean;
133
149
  },
134
150
  ): void {
135
- const { service, examples, omitAccount = false, note } = options;
151
+ const { service, examples, omitAccount = false, note, allowedSubcommands, vet, gmailNoSend = false } = options;
152
+ // The examples go straight into the schema the model reads, so every one must
153
+ // be a subcommand this tool actually runs. A stale example (#391: drive
154
+ // "upload" after #390 refused it) fails here, at registration, not in use.
155
+ for (const [, example] of examples.matchAll(/"([^"]+)"/g)) {
156
+ const refused = (allowedSubcommands && !allowedSubcommands.includes(example))
157
+ || vet?.(example, [])
158
+ || positionalPathTool(service, example);
159
+ if (refused) {
160
+ throw new Error(`gog_${service}_run example "${example}" is refused by the tool itself; list a subcommand it runs.`);
161
+ }
162
+ }
136
163
  const baseDescription = `Run any gog ${service} subcommand not covered by the other tools. Run \`gog ${service} --help\` for the full list of subcommands, or \`gog ${service} <subcommand> --help\` for flags on a specific subcommand.`;
137
- const description = note ? `${baseDescription} ${note}` : baseDescription;
164
+ const restriction = allowedSubcommands
165
+ ? ` Only these subcommands are available: ${allowedSubcommands.join(', ')}.`
166
+ : '';
167
+ const safety = ' Flags that override server safety controls (--readonly, --enable-commands, --disable-commands, --account, --access-token, --home, --client, --gmail-no-send, --no-input) and a bare "--" are refused.'
168
+ + ' Local file paths (--out, --out-dir, --attach, --file, --*-file, @file JSON) must be inside GOG_FILE_ROOTS; subcommands that take a local path positionally (e.g. drive upload) are refused in favour of their dedicated tools.';
169
+ const description = `${baseDescription}${restriction}${safety}${note ? ` ${note}` : ''}`;
138
170
  const inputSchema: Record<string, z.ZodTypeAny> = {
139
171
  subcommand: z.string().describe(`The gog ${service} subcommand to run, e.g. ${examples}`),
140
172
  args: z.array(z.string()).describe('Additional positional args and flags'),
@@ -148,7 +180,21 @@ export function registerRunTool(
148
180
  inputSchema: z.object(inputSchema),
149
181
  }, async (rawArgs) => {
150
182
  const { subcommand, args, account } = rawArgs as { subcommand: string; args: string[]; account?: string };
151
- return runOrDiagnose([service, subcommand, ...args], { account });
183
+ try {
184
+ assertSafeSubcommand(subcommand);
185
+ assertSafeForwardedArgs(args);
186
+ if (allowedSubcommands && !allowedSubcommands.includes(subcommand)) {
187
+ throw new Error(
188
+ `gog ${service} ${subcommand} is not available through gog_${service}_run. Allowed: ${allowedSubcommands.join(', ')}.`,
189
+ );
190
+ }
191
+ const refusal = vet?.(subcommand, args);
192
+ if (refusal) throw new Error(refusal);
193
+ assertRunPathsConfined(service, subcommand, args);
194
+ } catch (err) {
195
+ return errorResult(errorText(err));
196
+ }
197
+ return runOrDiagnose([service, subcommand, ...args], gmailNoSend ? { account, gmailNoSend } : { account });
152
198
  });
153
199
  }
154
200
 
@@ -359,7 +405,7 @@ function isRejectedFieldMask(err: unknown): boolean {
359
405
 
360
406
  async function runProjected(
361
407
  args: GogArg[],
362
- options: { account?: string; lossless?: boolean; fieldsMask?: string },
408
+ options: { account?: string; lossless?: boolean; fieldsMask?: string; gmailNoSend?: boolean },
363
409
  ): Promise<string> {
364
410
  const { fieldsMask } = options;
365
411
  if (!fieldsMask) return run(args, options);
@@ -377,7 +423,7 @@ async function runProjected(
377
423
 
378
424
  export async function runOrDiagnose(
379
425
  args: GogArg[],
380
- options: { account?: string; lossless?: boolean; fieldsMask?: string; stripMedia?: boolean },
426
+ options: { account?: string; lossless?: boolean; fieldsMask?: string; stripMedia?: boolean; gmailNoSend?: boolean },
381
427
  ): Promise<CallToolResult> {
382
428
  try {
383
429
  // The single seam every tool's output passes through. Normalizing here —
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { forbiddenArgReason, assertSafeForwardedArgs, assertSafeSubcommand } from '../src/arg-guard.js';
3
+
4
+ describe('forbiddenArgReason', () => {
5
+ // Each of these would let a model-supplied arg override a control the
6
+ // operator (or this wrapper) set: gog takes the LAST value of a repeated flag,
7
+ // so `--readonly=false` after the injected `--readonly` turns writes back on.
8
+ it.each([
9
+ '--readonly',
10
+ '--readonly=false',
11
+ '--readonly=0',
12
+ '--enable-commands=gmail.send',
13
+ '--enable-commands-exact=gmail send',
14
+ '--disable-commands=',
15
+ '--disable-commands',
16
+ '--access-token=ya29.x',
17
+ '--home=/tmp/other',
18
+ '--account=attacker@example.com',
19
+ '--account',
20
+ '--acct=attacker@example.com',
21
+ '--client=other',
22
+ '--gmail-no-send=false',
23
+ '--no-input=false',
24
+ '--non-interactive=false',
25
+ '--noninteractive=false',
26
+ '--READONLY=false',
27
+ '--Account=attacker@example.com',
28
+ '-a',
29
+ '-aattacker@example.com',
30
+ '-ja',
31
+ '--',
32
+ ])('rejects %j', (arg) => {
33
+ expect(forbiddenArgReason(arg)).toMatch(/not allowed/);
34
+ });
35
+
36
+ it.each([
37
+ 'msg1',
38
+ '--title=New',
39
+ '--max=5',
40
+ '-y',
41
+ '-5',
42
+ 'has:attachment',
43
+ 'a--readonly',
44
+ '',
45
+ // Legitimate command flags that merely share a leading word with a control
46
+ // (gog_zoom_auth_setup builds the first three). Matched as bare prefixes
47
+ // they were refused, so zoom auth setup could never run.
48
+ '--account-id=abc',
49
+ '--client-id=x',
50
+ '--client-secret=y',
51
+ '--home-dir=x',
52
+ '--readonly-note=x',
53
+ ])('allows %j', (arg) => {
54
+ expect(forbiddenArgReason(arg)).toBeUndefined();
55
+ });
56
+
57
+ it('names the separator specifically for a bare --', () => {
58
+ expect(forbiddenArgReason('--')).toMatch(/"--"/);
59
+ });
60
+ });
61
+
62
+ describe('assertSafeForwardedArgs', () => {
63
+ it('passes a clean list', () => {
64
+ expect(() => assertSafeForwardedArgs(['file1', '--parent=x'])).not.toThrow();
65
+ });
66
+
67
+ it('throws naming the first offending arg', () => {
68
+ expect(() => assertSafeForwardedArgs(['x', '--readonly=false'])).toThrow(/--readonly=false/);
69
+ });
70
+ });
71
+
72
+ describe('assertSafeSubcommand', () => {
73
+ it.each(['archive', 'mark-read', 'labels', 'add-tab'])('allows %j', (s) => {
74
+ expect(() => assertSafeSubcommand(s)).not.toThrow();
75
+ });
76
+
77
+ it.each(['--readonly=false', '-a', '', 'Send Now', '../x'])('rejects %j', (s) => {
78
+ expect(() => assertSafeSubcommand(s)).toThrow(/subcommand/);
79
+ });
80
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtemp, mkdir, writeFile, rm, stat, symlink, chmod, utimes, readdir } from 'node:fs/promises';
3
+ import { tmpdir, userInfo } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ ATTACHMENT_DOWNLOAD_ROOT,
7
+ ATTACHMENT_TTL_MS,
8
+ ensurePrivateDir,
9
+ sweepExpiredDownloads,
10
+ removeDownload,
11
+ prepareDownloadRoot,
12
+ } from '../src/attachment-root.js';
13
+
14
+ let scratch: string;
15
+ beforeEach(async () => {
16
+ scratch = await mkdtemp(join(tmpdir(), 'gog-attroot-test-'));
17
+ });
18
+ afterEach(async () => {
19
+ await rm(scratch, { recursive: true, force: true });
20
+ });
21
+
22
+ // SEC-6: downloads used to land in a fixed, shared /tmp/gog-attachments that
23
+ // nothing ever emptied — predictable, pre-creatable by another local user, and
24
+ // a growing pile of medical/financial attachments.
25
+ describe('ATTACHMENT_DOWNLOAD_ROOT', () => {
26
+ it('is per-user, under the OS temp dir, not the shared /tmp/gog-attachments', () => {
27
+ expect(ATTACHMENT_DOWNLOAD_ROOT).not.toBe('/tmp/gog-attachments');
28
+ expect(ATTACHMENT_DOWNLOAD_ROOT.startsWith(tmpdir())).toBe(true);
29
+ expect(ATTACHMENT_DOWNLOAD_ROOT).toContain(String(userInfo().uid));
30
+ });
31
+ });
32
+
33
+ describe('ensurePrivateDir', () => {
34
+ it('creates the directory owner-only (0700)', async () => {
35
+ const dir = join(scratch, 'root');
36
+ await ensurePrivateDir(dir);
37
+ expect((await stat(dir)).mode & 0o777).toBe(0o700);
38
+ });
39
+
40
+ it('tightens an existing directory of ours that others could read', async () => {
41
+ const dir = join(scratch, 'loose');
42
+ await mkdir(dir, { mode: 0o755 });
43
+ await chmod(dir, 0o755);
44
+ await ensurePrivateDir(dir);
45
+ expect((await stat(dir)).mode & 0o777).toBe(0o700);
46
+ });
47
+
48
+ it('refuses a symlink planted where the root should be', async () => {
49
+ const target = join(scratch, 'elsewhere');
50
+ await mkdir(target);
51
+ const dir = join(scratch, 'planted');
52
+ await symlink(target, dir);
53
+ await expect(ensurePrivateDir(dir)).rejects.toThrow(/not a real directory/);
54
+ });
55
+
56
+ it('refuses a regular file in its place', async () => {
57
+ const dir = join(scratch, 'file');
58
+ await writeFile(dir, 'x');
59
+ await expect(ensurePrivateDir(dir)).rejects.toThrow(/not a real directory/);
60
+ });
61
+
62
+ it('refuses a directory owned by another user', async () => {
63
+ const dir = join(scratch, 'theirs');
64
+ await mkdir(dir);
65
+ await expect(ensurePrivateDir(dir, { uid: userInfo().uid + 1 })).rejects.toThrow(/owned by another user/);
66
+ });
67
+
68
+ it('surfaces an error other than "already exists"', async () => {
69
+ await expect(ensurePrivateDir(join(scratch, 'missing-parent', 'root'))).rejects.toThrow(/ENOENT/);
70
+ });
71
+ });
72
+
73
+ describe('sweepExpiredDownloads', () => {
74
+ it('removes entries older than the TTL and keeps fresh ones', async () => {
75
+ const old = join(scratch, 'm-old');
76
+ const fresh = join(scratch, 'm-new');
77
+ await mkdir(old);
78
+ await mkdir(fresh);
79
+ await writeFile(join(old, 'a.pdf'), 'x');
80
+ const past = new Date(Date.now() - ATTACHMENT_TTL_MS - 60_000);
81
+ await utimes(old, past, past);
82
+ const removed = await sweepExpiredDownloads(scratch);
83
+ expect(removed).toBe(1);
84
+ expect(await readdir(scratch)).toEqual(['m-new']);
85
+ });
86
+
87
+ it('is a no-op on a missing root', async () => {
88
+ expect(await sweepExpiredDownloads(join(scratch, 'nope'))).toBe(0);
89
+ });
90
+ });
91
+
92
+ describe('removeDownload', () => {
93
+ it('deletes the file and the now-empty directories up to the root', async () => {
94
+ const file = join(scratch, 'm1', 'a1', 'x.pdf');
95
+ await mkdir(join(scratch, 'm1', 'a1'), { recursive: true });
96
+ await writeFile(file, 'x');
97
+ await removeDownload(file, scratch);
98
+ expect(await readdir(scratch)).toEqual([]);
99
+ });
100
+
101
+ it('keeps a directory that still holds another download', async () => {
102
+ await mkdir(join(scratch, 'm1'), { recursive: true });
103
+ await writeFile(join(scratch, 'm1', 'keep.pdf'), 'k');
104
+ await writeFile(join(scratch, 'm1', 'x.pdf'), 'x');
105
+ await removeDownload(join(scratch, 'm1', 'x.pdf'), scratch);
106
+ expect(await readdir(join(scratch, 'm1'))).toEqual(['keep.pdf']);
107
+ });
108
+
109
+ it('never touches a path outside the root', async () => {
110
+ const outside = join(scratch, 'outside.txt');
111
+ await writeFile(outside, 'x');
112
+ const root = join(scratch, 'root');
113
+ await mkdir(root);
114
+ await removeDownload(outside, root);
115
+ await removeDownload(join(root, '..', 'outside.txt'), root);
116
+ expect((await stat(outside)).isFile()).toBe(true);
117
+ });
118
+
119
+ it('ignores a file that is already gone', async () => {
120
+ await expect(removeDownload(join(scratch, 'gone', 'x'), scratch)).resolves.toBeUndefined();
121
+ });
122
+ });
123
+
124
+ describe('prepareDownloadRoot', () => {
125
+ it('ensures the root is private and sweeps it', async () => {
126
+ const root = join(scratch, 'prep');
127
+ await prepareDownloadRoot(root);
128
+ expect((await stat(root)).mode & 0o777).toBe(0o700);
129
+ });
130
+ });
@@ -9,6 +9,7 @@ import {
9
9
  INLINE_ATTACHMENT_LIMITS_TEXT,
10
10
  } from '../src/attachments.js';
11
11
  import type { GogFileArg } from '../src/runner.js';
12
+ import { pos } from '../src/argv.js';
12
13
 
13
14
  const b64 = (s: string): string => Buffer.from(s, 'utf8').toString('base64');
14
15
  const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -166,6 +167,13 @@ describe('inlineAttachmentArgs', () => {
166
167
  .toThrow(/too large to send/);
167
168
  });
168
169
 
170
+ it('counts a pos()-marked sibling at its UTF-8 length', () => {
171
+ const each = Buffer.alloc(Math.floor((MAX_INLINE_ATTACHMENT_TOTAL_BYTES - 4096) / 3)).toString('base64');
172
+ const attachments = Array.from({ length: 3 }, (_, i) => ({ filename: `f${i}.bin`, contentBase64: each }));
173
+ expect(() => inlineAttachmentArgs('attach', attachments, ['chat', 'messages', 'send', pos('x'.repeat(4 * 1024 * 1024))]))
174
+ .toThrow(/would fit on their own/);
175
+ });
176
+
169
177
  it('ignores small sibling args, which the JSON reserve already covers', () => {
170
178
  const chunk = Buffer.alloc(Math.floor(MAX_INLINE_ATTACHMENT_TOTAL_BYTES / 4)).toString('base64');
171
179
  const two = Array.from({ length: 2 }, (_, i) => ({ filename: `f${i}.bin`, contentBase64: chunk }));
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
3
3
  import type { AddressInfo } from 'node:net';
4
4
  import { mkdtemp, mkdir, writeFile, symlink, truncate, rm } from 'node:fs/promises';
5
- import { tmpdir } from 'node:os';
5
+ import { tmpdir, userInfo } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import {
8
8
  uploadToBlobStore,
@@ -147,11 +147,12 @@ describe('uploadToBlobStore', () => {
147
147
  });
148
148
 
149
149
  it('confines to the real attachment root when no root is given', async () => {
150
- expect(ATTACHMENT_DOWNLOAD_ROOT).toBe('/tmp/gog-attachments');
150
+ // Per-user and private now (SEC-6), not the shared /tmp/gog-attachments.
151
+ expect(ATTACHMENT_DOWNLOAD_ROOT).toBe(join(tmpdir(), `gogcli-mcp-attachments-${userInfo().uid}`));
151
152
 
152
153
  const err = await failure(uploadToBlobStore(request({ path: '/etc/passwd' })));
153
154
 
154
- expect(err.message).toContain('must be inside /tmp/gog-attachments');
155
+ expect(err.message).toContain(`must be inside ${ATTACHMENT_DOWNLOAD_ROOT}`);
155
156
  expect(received).toHaveLength(0);
156
157
  });
157
158
  });
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { delimiter, join } from 'node:path';
5
+ import { confinePath, confinePaths, confineAtFile, defaultFileRoot, fileRoots, FILE_ROOTS_ENV } from '../src/file-roots.js';
6
+ import { ATTACHMENT_DOWNLOAD_ROOT } from '../src/attachment-root.js';
7
+
8
+ let scratch: string;
9
+ let saved: string | undefined;
10
+ beforeEach(async () => {
11
+ scratch = await mkdtemp(join(tmpdir(), 'gog-roots-test-'));
12
+ saved = process.env[FILE_ROOTS_ENV];
13
+ process.env[FILE_ROOTS_ENV] = scratch;
14
+ });
15
+ afterEach(async () => {
16
+ if (saved === undefined) delete process.env[FILE_ROOTS_ENV];
17
+ else process.env[FILE_ROOTS_ENV] = saved;
18
+ await rm(scratch, { recursive: true, force: true });
19
+ });
20
+
21
+ describe('fileRoots', () => {
22
+ it('reads a path-delimited list from GOG_FILE_ROOTS, ignoring blanks', () => {
23
+ process.env[FILE_ROOTS_ENV] = `/a${delimiter} ${delimiter}/b`;
24
+ expect(fileRoots()).toEqual(['/a', '/b']);
25
+ });
26
+
27
+ it('defaults to a dedicated directory under the home directory', () => {
28
+ delete process.env[FILE_ROOTS_ENV];
29
+ expect(defaultFileRoot()).toBe(join(homedir(), 'gogcli-mcp-files'));
30
+ expect(fileRoots()).toEqual([defaultFileRoot()]);
31
+ });
32
+
33
+ it('treats an unresolved .mcpb placeholder as unset', () => {
34
+ process.env[FILE_ROOTS_ENV] = '${user_config.gog_file_roots}';
35
+ expect(fileRoots()).toEqual([defaultFileRoot()]);
36
+ });
37
+ });
38
+
39
+ // SEC-3/SEC-4: every model-supplied server path — attach, localPath, out,
40
+ // outDir — is a read or write primitive on the host. ~/.ssh, gog's keyring,
41
+ // /proc/<pid>/environ and ~/Library/LaunchAgents are all one argument away.
42
+ describe('confinePath', () => {
43
+ it('accepts a path inside a root and returns it unchanged', () => {
44
+ const p = join(scratch, 'report.pdf');
45
+ expect(confinePath(p, 'attach')).toBe(p);
46
+ });
47
+
48
+ it('accepts a not-yet-existing output path inside a root', () => {
49
+ expect(confinePath(join(scratch, 'new', 'out.csv'), 'out')).toBe(join(scratch, 'new', 'out.csv'));
50
+ });
51
+
52
+ it('accepts the private attachment download directory', () => {
53
+ const p = join(ATTACHMENT_DOWNLOAD_ROOT, 'm1', 'a.pdf');
54
+ expect(confinePath(p, 'attach')).toBe(p);
55
+ });
56
+
57
+ it.each([
58
+ ['/etc/passwd'],
59
+ [join(homedir(), '.ssh', 'id_rsa')],
60
+ ['/proc/self/environ'],
61
+ ])('refuses %s, naming the parameter and GOG_FILE_ROOTS', (p) => {
62
+ expect(() => confinePath(p, 'attach')).toThrow(/attach .* is outside the directories .* GOG_FILE_ROOTS/);
63
+ });
64
+
65
+ it('refuses a .. walk out of the root', () => {
66
+ expect(() => confinePath(join(scratch, '..', 'x'), 'out')).toThrow(/outside/);
67
+ });
68
+
69
+ it('refuses a symlink inside the root that points outside it', async () => {
70
+ const outside = await mkdtemp(join(tmpdir(), 'gog-roots-outside-'));
71
+ try {
72
+ await writeFile(join(outside, 'secret'), 's');
73
+ await mkdir(join(scratch, 'd'));
74
+ await symlink(join(outside, 'secret'), join(scratch, 'd', 'link'));
75
+ expect(() => confinePath(join(scratch, 'd', 'link'), 'localPath')).toThrow(/outside/);
76
+ } finally {
77
+ await rm(outside, { recursive: true, force: true });
78
+ }
79
+ });
80
+
81
+ it('refuses gog\'s "-" (stdin) unless the parameter allows stdout', () => {
82
+ expect(() => confinePath('-', 'file')).toThrow(/outside/);
83
+ expect(confinePath('-', 'out', { allowDash: true })).toBe('-');
84
+ });
85
+ });
86
+
87
+ describe('confinePaths', () => {
88
+ it('checks every path and tolerates undefined', () => {
89
+ expect(() => confinePaths(undefined, 'attach')).not.toThrow();
90
+ expect(() => confinePaths([join(scratch, 'a'), '/etc/hosts'], 'attach')).toThrow(/\/etc\/hosts/);
91
+ });
92
+ });
93
+
94
+ describe('confineAtFile', () => {
95
+ it('confines the path of an @file value and passes inline JSON through', () => {
96
+ expect(confineAtFile('[{"a":1}]', 'cellsJson')).toBe('[{"a":1}]');
97
+ expect(confineAtFile(`@${join(scratch, 'c.json')}`, 'cellsJson')).toBe(`@${join(scratch, 'c.json')}`);
98
+ expect(() => confineAtFile('@/etc/passwd', 'cellsJson')).toThrow(/cellsJson/);
99
+ expect(() => confineAtFile('@-', 'cellsJson')).toThrow(/outside/);
100
+ });
101
+ });