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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +1340 -262
- package/dist/lib.js +1381 -260
- package/manifest.json +9 -2
- 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/dispatch-confirmation.ts +277 -0
- package/src/file-roots.ts +66 -0
- package/src/gmail-dispatch-guard.ts +70 -30
- package/src/gmail-results.ts +21 -2
- package/src/lib.ts +15 -0
- package/src/run-path-guard.ts +118 -0
- package/src/runner.ts +86 -18
- package/src/send-confirm-token.ts +167 -0
- package/src/tools/api.ts +60 -7
- package/src/tools/appscript.ts +12 -8
- package/src/tools/auth.ts +13 -3
- package/src/tools/calendar.ts +178 -15
- package/src/tools/chat.ts +94 -18
- package/src/tools/classroom.ts +110 -28
- package/src/tools/contacts.ts +5 -3
- package/src/tools/docs.ts +8 -6
- package/src/tools/drive.ts +96 -20
- package/src/tools/gmail.ts +175 -24
- 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 +270 -11
- 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/send-confirm-token.test.ts +200 -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 +33 -28
- package/tests/tools/chat.test.ts +50 -21
- package/tests/tools/classroom.test.ts +43 -39
- package/tests/tools/contacts.test.ts +3 -2
- package/tests/tools/dispatch-gates.test.ts +396 -0
- package/tests/tools/docs.test.ts +44 -15
- package/tests/tools/drive.test.ts +110 -20
- package/tests/tools/gmail-confirm-token.test.ts +274 -0
- package/tests/tools/gmail.test.ts +227 -29
- package/tests/tools/run-tool-examples.test.ts +69 -0
- package/tests/tools/run-vets.test.ts +131 -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/src/tools/utils.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
});
|