@marcusrbrown/infra 0.9.7 → 0.9.8
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/package.json +1 -1
- package/src/commands/mcp.test.ts +20 -290
- package/src/commands/mcp.ts +14 -6
- package/src/conventions.test.ts +80 -0
package/package.json
CHANGED
package/src/commands/mcp.test.ts
CHANGED
|
@@ -23,12 +23,9 @@
|
|
|
23
23
|
* // proc.kill()
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js'
|
|
27
|
-
|
|
28
26
|
import {createMcpAction} from '@goke/mcp'
|
|
29
27
|
import {Client} from '@modelcontextprotocol/sdk/client'
|
|
30
28
|
import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js'
|
|
31
|
-
import {CallToolResultSchema} from '@modelcontextprotocol/sdk/types.js'
|
|
32
29
|
import {afterAll, beforeAll, describe, expect, test} from 'bun:test'
|
|
33
30
|
import {goke} from 'goke'
|
|
34
31
|
|
|
@@ -41,11 +38,20 @@ import {registerUmamiCommands} from './umami'
|
|
|
41
38
|
|
|
42
39
|
// ─── Tool name constants ──────────────────────────────────────────────────────
|
|
43
40
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The MCP tool surface, pinned as an INDEPENDENT literal (deliberately NOT
|
|
43
|
+
* derived from MCP_ALLOWLIST) so allowlist drift surfaces as a test failure —
|
|
44
|
+
* per the Tier-1 test bar in packages/cli/AGENTS.md. Five read-only status tools.
|
|
45
|
+
*/
|
|
46
|
+
const EXPECTED_TOOLS = ['cliproxy_status', 'gateway_status', 'keeweb_status', 'status', 'umami_status'].sort()
|
|
46
47
|
|
|
47
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* CLI-only commands that must NOT appear in the MCP tool list: the
|
|
50
|
+
* non-interactive/streaming-only commands plus the source-gated sensitive
|
|
51
|
+
* commands (mutating or secret-disclosing).
|
|
52
|
+
*/
|
|
48
53
|
const CLI_ONLY_TOOLS = [
|
|
54
|
+
// Non-interactive / streaming — excluded since before the source-gate change
|
|
49
55
|
'cliproxy_deploy',
|
|
50
56
|
'cliproxy_login',
|
|
51
57
|
'cliproxy_open',
|
|
@@ -57,6 +63,13 @@ const CLI_ONLY_TOOLS = [
|
|
|
57
63
|
'keeweb_open',
|
|
58
64
|
'umami_deploy',
|
|
59
65
|
'umami_logs',
|
|
66
|
+
// Source-gated sensitive commands (mutating or secret-disclosing)
|
|
67
|
+
'gateway_backup',
|
|
68
|
+
'cliproxy_keys_list',
|
|
69
|
+
'cliproxy_keys_add',
|
|
70
|
+
'cliproxy_keys_remove',
|
|
71
|
+
'cliproxy_config_get',
|
|
72
|
+
'cliproxy_config_set',
|
|
60
73
|
].sort()
|
|
61
74
|
|
|
62
75
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -73,14 +86,6 @@ function buildTestCli(): ReturnType<typeof goke> {
|
|
|
73
86
|
return cli
|
|
74
87
|
}
|
|
75
88
|
|
|
76
|
-
/** Extract all text block content from a CallToolResult, joined by newline. */
|
|
77
|
-
function allText(result: CallToolResult): string {
|
|
78
|
-
return result.content
|
|
79
|
-
.filter((block): block is {type: 'text'; text: string} => block.type === 'text')
|
|
80
|
-
.map(block => block.text)
|
|
81
|
-
.join('\n')
|
|
82
|
-
}
|
|
83
|
-
|
|
84
89
|
// ─── Suite ────────────────────────────────────────────────────────────────────
|
|
85
90
|
|
|
86
91
|
describe('mcp integration (Tier-1, in-process)', () => {
|
|
@@ -123,286 +128,11 @@ describe('mcp integration (Tier-1, in-process)', () => {
|
|
|
123
128
|
expect(names).toEqual(EXPECTED_TOOLS)
|
|
124
129
|
})
|
|
125
130
|
|
|
126
|
-
test('
|
|
131
|
+
test('source-gated sensitive commands are absent from the MCP tool list', async () => {
|
|
127
132
|
const result = await client.listTools()
|
|
128
133
|
const names = new Set(result.tools.map((t: {name: string}) => t.name))
|
|
129
134
|
for (const excluded of CLI_ONLY_TOOLS) {
|
|
130
135
|
expect(names.has(excluded)).toBe(false)
|
|
131
136
|
}
|
|
132
137
|
})
|
|
133
|
-
|
|
134
|
-
// ── Mode B contract: gateway_backup ───────────────────────────────────────
|
|
135
|
-
//
|
|
136
|
-
// gateway_backup reads GATEWAY_HOST before attempting any SSH call.
|
|
137
|
-
// When GATEWAY_HOST is unset the action exits synchronously with a
|
|
138
|
-
// well-known error message — no network contact is made.
|
|
139
|
-
// The MCP layer must wrap that failure as an error CallToolResult
|
|
140
|
-
// (isError: true) rather than throwing, and the content must contain
|
|
141
|
-
// the exact error message from gatewayBackupAction so we catch contract
|
|
142
|
-
// regressions if the message ever changes.
|
|
143
|
-
|
|
144
|
-
test('gateway_backup wraps missing-GATEWAY_HOST as an error CallToolResult with the known message', async () => {
|
|
145
|
-
const originalHost = process.env.GATEWAY_HOST
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
// Delete GATEWAY_HOST to trigger the fast synchronous error path inside
|
|
149
|
-
// gatewayBackupAction — no SSH or network call is made.
|
|
150
|
-
delete process.env.GATEWAY_HOST
|
|
151
|
-
|
|
152
|
-
const rawResult = await client.callTool(
|
|
153
|
-
{
|
|
154
|
-
name: 'gateway_backup',
|
|
155
|
-
arguments: {},
|
|
156
|
-
},
|
|
157
|
-
CallToolResultSchema,
|
|
158
|
-
)
|
|
159
|
-
|
|
160
|
-
const result: CallToolResult = CallToolResultSchema.parse(rawResult)
|
|
161
|
-
|
|
162
|
-
// The MCP layer must surface the error as a content block, not a thrown exception.
|
|
163
|
-
expect(result.isError).toBe(true)
|
|
164
|
-
expect(result.content.length).toBeGreaterThan(0)
|
|
165
|
-
|
|
166
|
-
// The content must carry the exact contract-bearing error string from
|
|
167
|
-
// gatewayBackupAction. This assertion fails if the error message changes.
|
|
168
|
-
expect(allText(result)).toContain('Gateway host not set. Export GATEWAY_HOST before running backup.')
|
|
169
|
-
} finally {
|
|
170
|
-
if (originalHost === undefined) {
|
|
171
|
-
delete process.env.GATEWAY_HOST
|
|
172
|
-
} else {
|
|
173
|
-
process.env.GATEWAY_HOST = originalHost
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
// ── Mode C contract: cliproxy_keys_list ───────────────────────────────────
|
|
179
|
-
//
|
|
180
|
-
// Mode C: when a command returns structured data AND prints to stdout,
|
|
181
|
-
// the CallToolResult must contain BOTH a stdout text block AND a
|
|
182
|
-
// stringified return-value text block.
|
|
183
|
-
test('cliproxy_keys_list returns BOTH stdout block AND structured return block (Mode C contract)', async () => {
|
|
184
|
-
const originalFetch = globalThis.fetch
|
|
185
|
-
const originalKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
process.env.CLIPROXY_MANAGEMENT_KEY = 'test-management-key'
|
|
189
|
-
|
|
190
|
-
globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
191
|
-
return new Response(JSON.stringify(['fro-bot-test1', 'fro-bot-test2']), {
|
|
192
|
-
status: 200,
|
|
193
|
-
headers: {'content-type': 'application/json'},
|
|
194
|
-
})
|
|
195
|
-
}) as typeof fetch
|
|
196
|
-
|
|
197
|
-
const rawResult = await client.callTool(
|
|
198
|
-
{
|
|
199
|
-
name: 'cliproxy_keys_list',
|
|
200
|
-
arguments: {},
|
|
201
|
-
},
|
|
202
|
-
CallToolResultSchema,
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
const result: CallToolResult = CallToolResultSchema.parse(rawResult)
|
|
206
|
-
|
|
207
|
-
// Mode C contract: at least 2 content blocks (stdout text + structured return)
|
|
208
|
-
expect(result.content.length).toBeGreaterThanOrEqual(2)
|
|
209
|
-
|
|
210
|
-
const text = allText(result)
|
|
211
|
-
|
|
212
|
-
// One block must contain the formatted stdout (key names printed by the action)
|
|
213
|
-
expect(text).toContain('fro-bot-test1')
|
|
214
|
-
|
|
215
|
-
// One block must contain the stringified return value (JSON array of key names)
|
|
216
|
-
const hasStructuredReturn = result.content.some(block => {
|
|
217
|
-
if (block.type !== 'text') return false
|
|
218
|
-
try {
|
|
219
|
-
const parsed = JSON.parse(block.text) as unknown
|
|
220
|
-
return Array.isArray(parsed) && parsed.includes('fro-bot-test1') && parsed.includes('fro-bot-test2')
|
|
221
|
-
} catch {
|
|
222
|
-
return false
|
|
223
|
-
}
|
|
224
|
-
})
|
|
225
|
-
expect(hasStructuredReturn).toBe(true)
|
|
226
|
-
} finally {
|
|
227
|
-
globalThis.fetch = originalFetch
|
|
228
|
-
if (originalKey === undefined) {
|
|
229
|
-
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
230
|
-
} else {
|
|
231
|
-
process.env.CLIPROXY_MANAGEMENT_KEY = originalKey
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
// ── Positional-arg routing: cliproxy_keys_add ─────────────────────────────
|
|
237
|
-
//
|
|
238
|
-
// The MCP inputSchema for cliproxy_keys_add has a single required property
|
|
239
|
-
// `key` (derived from the command's positional `<key>` argument). This test
|
|
240
|
-
// proves that goke routes the MCP argument value through to the action's
|
|
241
|
-
// positional parameter (apiKeyToAdd) by asserting the key name appears in
|
|
242
|
-
// the action's output text.
|
|
243
|
-
//
|
|
244
|
-
// Verified inputSchema (empirical): { properties: { key: { type: "string",
|
|
245
|
-
// description: "Management API key…" }, url: { type: "string" } },
|
|
246
|
-
// required: ["key"] }
|
|
247
|
-
|
|
248
|
-
test('cliproxy_keys_add routes the positional key argument through MCP', async () => {
|
|
249
|
-
const originalFetch = globalThis.fetch
|
|
250
|
-
const originalKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
251
|
-
|
|
252
|
-
try {
|
|
253
|
-
process.env.CLIPROXY_MANAGEMENT_KEY = 'test-management-key'
|
|
254
|
-
|
|
255
|
-
globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
256
|
-
const method = typeof _init?.method === 'string' ? _init.method.toUpperCase() : 'GET'
|
|
257
|
-
if (method === 'GET') {
|
|
258
|
-
// Return empty current key list so the new key is not already present
|
|
259
|
-
return new Response(JSON.stringify([]), {
|
|
260
|
-
status: 200,
|
|
261
|
-
headers: {'content-type': 'application/json'},
|
|
262
|
-
})
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// PUT response — the action ignores the body and logs the new count
|
|
266
|
-
return new Response(JSON.stringify(['tier1-test-key']), {
|
|
267
|
-
status: 200,
|
|
268
|
-
headers: {'content-type': 'application/json'},
|
|
269
|
-
})
|
|
270
|
-
}) as typeof fetch
|
|
271
|
-
|
|
272
|
-
const rawResult = await client.callTool(
|
|
273
|
-
{
|
|
274
|
-
name: 'cliproxy_keys_add',
|
|
275
|
-
// `key` is the positional <key> arg (the API key to add).
|
|
276
|
-
// Verified from listTools() inputSchema — required property.
|
|
277
|
-
arguments: {key: 'tier1-test-key'},
|
|
278
|
-
},
|
|
279
|
-
CallToolResultSchema,
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
const result: CallToolResult = CallToolResultSchema.parse(rawResult)
|
|
283
|
-
|
|
284
|
-
expect(result.isError).not.toBe(true)
|
|
285
|
-
// The action logs `Added key "tier1-test-key"` — presence proves the
|
|
286
|
-
// positional arg was correctly routed from the MCP call.
|
|
287
|
-
expect(allText(result)).toContain('tier1-test-key')
|
|
288
|
-
} finally {
|
|
289
|
-
globalThis.fetch = originalFetch
|
|
290
|
-
if (originalKey === undefined) {
|
|
291
|
-
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
292
|
-
} else {
|
|
293
|
-
process.env.CLIPROXY_MANAGEMENT_KEY = originalKey
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
// ── Positional-arg routing: cliproxy_keys_remove ──────────────────────────
|
|
299
|
-
//
|
|
300
|
-
// The MCP inputSchema for cliproxy_keys_remove has a required property `key`
|
|
301
|
-
// (derived from the positional `<key>` argument). The action issues a DELETE
|
|
302
|
-
// request with `?value=<apiKeyToRemove>` and logs the response. The fetch mock
|
|
303
|
-
// echoes back the query-parameter value so we can assert it was routed correctly.
|
|
304
|
-
//
|
|
305
|
-
// Verified inputSchema (empirical): { properties: { key: { type: "string" },
|
|
306
|
-
// url: { type: "string" } }, required: ["key"] }
|
|
307
|
-
|
|
308
|
-
test('cliproxy_keys_remove routes the positional key argument through MCP', async () => {
|
|
309
|
-
const originalFetch = globalThis.fetch
|
|
310
|
-
const originalKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
311
|
-
|
|
312
|
-
try {
|
|
313
|
-
process.env.CLIPROXY_MANAGEMENT_KEY = 'test-management-key'
|
|
314
|
-
|
|
315
|
-
globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
316
|
-
// The action appends ?value=<apiKeyToRemove> — echo it back in the
|
|
317
|
-
// response body so the assertion can verify positional routing.
|
|
318
|
-
const urlStr = typeof _url === 'string' ? _url : _url instanceof URL ? _url.href : (_url as Request).url
|
|
319
|
-
const url = new URL(urlStr)
|
|
320
|
-
const removedKey = url.searchParams.get('value') ?? 'unknown'
|
|
321
|
-
return new Response(JSON.stringify({removed: removedKey}), {
|
|
322
|
-
status: 200,
|
|
323
|
-
headers: {'content-type': 'application/json'},
|
|
324
|
-
})
|
|
325
|
-
}) as typeof fetch
|
|
326
|
-
|
|
327
|
-
const rawResult = await client.callTool(
|
|
328
|
-
{
|
|
329
|
-
name: 'cliproxy_keys_remove',
|
|
330
|
-
// `key` is the positional <key> arg (the API key to remove).
|
|
331
|
-
// Verified from listTools() inputSchema — required property.
|
|
332
|
-
arguments: {key: 'tier1-test-remove-key'},
|
|
333
|
-
},
|
|
334
|
-
CallToolResultSchema,
|
|
335
|
-
)
|
|
336
|
-
|
|
337
|
-
const result: CallToolResult = CallToolResultSchema.parse(rawResult)
|
|
338
|
-
|
|
339
|
-
expect(result.isError).not.toBe(true)
|
|
340
|
-
// The response includes the echoed ?value= param — proves the positional
|
|
341
|
-
// arg was routed to the DELETE URL query parameter.
|
|
342
|
-
expect(allText(result)).toContain('tier1-test-remove-key')
|
|
343
|
-
} finally {
|
|
344
|
-
globalThis.fetch = originalFetch
|
|
345
|
-
if (originalKey === undefined) {
|
|
346
|
-
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
347
|
-
} else {
|
|
348
|
-
process.env.CLIPROXY_MANAGEMENT_KEY = originalKey
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
// ── Positional-arg routing: cliproxy_config_set ───────────────────────────
|
|
354
|
-
//
|
|
355
|
-
// The MCP inputSchema for cliproxy_config_set has two required properties:
|
|
356
|
-
// `field` and `value` (both positional). The action PUT-s `{value: <parsed>}`
|
|
357
|
-
// to the field-specific endpoint and logs the response JSON. Returning the
|
|
358
|
-
// value in the mock response lets us assert both positionals were routed.
|
|
359
|
-
//
|
|
360
|
-
// Verified inputSchema (empirical): { properties: { field: { type: "string",
|
|
361
|
-
// description: "Positional argument field" }, value: { type: "string",
|
|
362
|
-
// description: "Positional argument value" }, url: …, key: … },
|
|
363
|
-
// required: ["field","value"] }
|
|
364
|
-
|
|
365
|
-
test('cliproxy_config_set routes both positional arguments (field and value) through MCP', async () => {
|
|
366
|
-
const originalFetch = globalThis.fetch
|
|
367
|
-
const originalKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
368
|
-
|
|
369
|
-
try {
|
|
370
|
-
process.env.CLIPROXY_MANAGEMENT_KEY = 'test-management-key'
|
|
371
|
-
|
|
372
|
-
globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
373
|
-
// Echo back the parsed body so we can assert the value was routed.
|
|
374
|
-
// The action sends {value: <parsedValue>}. We return it as-is.
|
|
375
|
-
const body = _init?.body
|
|
376
|
-
const parsed: unknown = body ? JSON.parse(String(body)) : {}
|
|
377
|
-
return new Response(JSON.stringify(parsed), {
|
|
378
|
-
status: 200,
|
|
379
|
-
headers: {'content-type': 'application/json'},
|
|
380
|
-
})
|
|
381
|
-
}) as typeof fetch
|
|
382
|
-
|
|
383
|
-
const rawResult = await client.callTool(
|
|
384
|
-
{
|
|
385
|
-
name: 'cliproxy_config_set',
|
|
386
|
-
// `field` and `value` are the positional args from `<field> <value>`.
|
|
387
|
-
// `proxy-url` is a string-type mutable field — no parsing needed.
|
|
388
|
-
arguments: {field: 'proxy-url', value: 'tier1-test-url'},
|
|
389
|
-
},
|
|
390
|
-
CallToolResultSchema,
|
|
391
|
-
)
|
|
392
|
-
|
|
393
|
-
const result: CallToolResult = CallToolResultSchema.parse(rawResult)
|
|
394
|
-
|
|
395
|
-
expect(result.isError).not.toBe(true)
|
|
396
|
-
// The response echoes back {value: "tier1-test-url"} — presence of the
|
|
397
|
-
// value proves positional routing worked end-to-end.
|
|
398
|
-
expect(allText(result)).toContain('tier1-test-url')
|
|
399
|
-
} finally {
|
|
400
|
-
globalThis.fetch = originalFetch
|
|
401
|
-
if (originalKey === undefined) {
|
|
402
|
-
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
403
|
-
} else {
|
|
404
|
-
process.env.CLIPROXY_MANAGEMENT_KEY = originalKey
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
})
|
|
408
138
|
})
|
package/src/commands/mcp.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {createMcpAction} from '@goke/mcp'
|
|
|
4
4
|
/**
|
|
5
5
|
* Commands intentionally excluded from MCP exposure (not in MCP_ALLOWLIST):
|
|
6
6
|
*
|
|
7
|
+
* Infrastructure / streaming (deferred or not MCP-suitable):
|
|
7
8
|
* - `gateway deploy` — subprocess streaming (Bun.spawn with stdout: 'inherit'), deferred to MCP v2 (#291)
|
|
8
9
|
* - `cliproxy deploy` — subprocess streaming, deferred to MCP v2 (#291)
|
|
9
10
|
* - `keeweb deploy` — subprocess streaming, deferred to MCP v2 (#291)
|
|
@@ -15,16 +16,23 @@ import {createMcpAction} from '@goke/mcp'
|
|
|
15
16
|
* - `keeweb open` — host-machine side effect (spawns local browser, requires user intent)
|
|
16
17
|
* - `umami deploy` — intentionally CLI-only: mutates live deployment and requires environment approval
|
|
17
18
|
* - `umami logs` — intentionally CLI-only: streams logs that may emit sensitive data (DB passwords, app secrets)
|
|
19
|
+
*
|
|
20
|
+
* Source-gated sensitive commands (security — CLI-only, never MCP-exposed):
|
|
21
|
+
* - `gateway backup` — secret-bearing: writes CA private key material to a tarball; CLI-only
|
|
22
|
+
* - `cliproxy keys list` — secret-disclosing: prints live bearer tokens in plaintext; CLI-only
|
|
23
|
+
* - `cliproxy keys add` — mutating: creates live bearer tokens on the proxy; CLI-only
|
|
24
|
+
* - `cliproxy keys remove` — mutating: revokes live bearer tokens on the proxy; CLI-only
|
|
25
|
+
* - `cliproxy config get` — secret-disclosing: dumps management config incl. management key; CLI-only
|
|
26
|
+
* - `cliproxy config set` — mutating: overwrites CLIProxyAPI runtime config; CLI-only
|
|
27
|
+
*
|
|
28
|
+
* A defense-in-depth backstop also denies these in opencode.jsonc `permission`
|
|
29
|
+
* so that even if MCP_ALLOWLIST were mistakenly re-expanded, opencode's native
|
|
30
|
+
* tool permission check would block execution. Both layers are enforced by
|
|
31
|
+
* the `gates every sensitive infra MCP tool` test in conventions.test.ts.
|
|
18
32
|
*/
|
|
19
33
|
export const MCP_ALLOWLIST: ReadonlySet<string> = new Set([
|
|
20
34
|
'gateway status',
|
|
21
|
-
'gateway backup',
|
|
22
35
|
'cliproxy status',
|
|
23
|
-
'cliproxy keys list',
|
|
24
|
-
'cliproxy keys add',
|
|
25
|
-
'cliproxy keys remove',
|
|
26
|
-
'cliproxy config get',
|
|
27
|
-
'cliproxy config set',
|
|
28
36
|
'keeweb status',
|
|
29
37
|
'umami status',
|
|
30
38
|
'status',
|
package/src/conventions.test.ts
CHANGED
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
import {relative, resolve} from 'node:path'
|
|
2
2
|
import {describe, expect, it} from 'bun:test'
|
|
3
3
|
import {parse as parseYaml} from 'yaml'
|
|
4
|
+
import {MCP_ALLOWLIST} from './commands/mcp'
|
|
4
5
|
|
|
5
6
|
const REPO_ROOT = resolve(import.meta.dir, '../../..')
|
|
6
7
|
const INTERNAL_ORGS = new Set(['marcusrbrown'])
|
|
7
8
|
const ALLOWED_SHELL_SCRIPTS = new Set(['apps/keeweb/deploy.sh'])
|
|
8
9
|
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// MCP drift-guard: sensitive tool set (two-layer security model)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
//
|
|
14
|
+
// These 6 commands are source-gated: they are NOT in MCP_ALLOWLIST and are
|
|
15
|
+
// therefore never registered as MCP tools. They remain CLI-only.
|
|
16
|
+
//
|
|
17
|
+
// WHY each is source-gated (primary layer — MCP_ALLOWLIST exclusion):
|
|
18
|
+
// cliproxy keys add — mutating: creates live bearer tokens on the proxy
|
|
19
|
+
// cliproxy keys remove — mutating: revokes live bearer tokens
|
|
20
|
+
// cliproxy config set — mutating: overwrites CLIProxyAPI runtime config
|
|
21
|
+
// gateway backup — secret-bearing: writes CA private key material to a tarball
|
|
22
|
+
// cliproxy keys list — secret-disclosing: prints live bearer tokens in plaintext
|
|
23
|
+
// cliproxy config get — secret-disclosing: dumps management config incl. management key
|
|
24
|
+
//
|
|
25
|
+
// Defense-in-depth (secondary layer — opencode.jsonc `permission: deny`):
|
|
26
|
+
// Even if MCP_ALLOWLIST were mistakenly re-expanded, opencode's native tool
|
|
27
|
+
// permission check provides a backstop that denies these tool calls centrally
|
|
28
|
+
// before execution. Both layers must stay in sync; the test below enforces it.
|
|
29
|
+
//
|
|
30
|
+
// A conventions test below asserts every entry (a) is NOT in MCP_ALLOWLIST
|
|
31
|
+
// (source-gated out) and (b) is still denied in opencode.jsonc under the
|
|
32
|
+
// prefixed tool id (defense-in-depth backstop).
|
|
33
|
+
const SENSITIVE_MCP_COMMANDS: readonly string[] = [
|
|
34
|
+
'cliproxy keys add',
|
|
35
|
+
'cliproxy keys remove',
|
|
36
|
+
'cliproxy config set',
|
|
37
|
+
'gateway backup',
|
|
38
|
+
'cliproxy keys list',
|
|
39
|
+
'cliproxy config get',
|
|
40
|
+
]
|
|
41
|
+
|
|
9
42
|
// Accepted version-comment forms on SHA-pinned `uses:` lines:
|
|
10
43
|
// # v6.0.2 (semver tag)
|
|
11
44
|
// # renovate-changesets@0.2.31 (scoped release tag)
|
|
@@ -242,6 +275,53 @@ describe('repo conventions', () => {
|
|
|
242
275
|
const offenders = files.map(f => relative(REPO_ROOT, f)).filter(f => !ALLOWED_SHELL_SCRIPTS.has(f))
|
|
243
276
|
expect(offenders).toEqual([])
|
|
244
277
|
})
|
|
278
|
+
|
|
279
|
+
it('gates every sensitive infra MCP tool in opencode.jsonc', async () => {
|
|
280
|
+
// Parse opencode.jsonc tolerantly: it uses JSONC syntax (// line comments,
|
|
281
|
+
// trailing commas). Strategy:
|
|
282
|
+
// 1. Strip full-line comments — lines whose first non-whitespace is `//`.
|
|
283
|
+
// This avoids breaking the `https://` in the $schema string value.
|
|
284
|
+
// 2. Strip trailing commas before `}` or `]`.
|
|
285
|
+
// 3. JSON.parse the result.
|
|
286
|
+
const jsoncText = await Bun.file(resolve(REPO_ROOT, 'opencode.jsonc')).text()
|
|
287
|
+
const stripped = jsoncText
|
|
288
|
+
.split('\n')
|
|
289
|
+
.map(line => (/^\s*\/\//.test(line) ? '' : line))
|
|
290
|
+
.join('\n')
|
|
291
|
+
.replaceAll(/,(\s*[}\]])/g, '$1')
|
|
292
|
+
const opencode = JSON.parse(stripped) as {permission?: Record<string, unknown>}
|
|
293
|
+
|
|
294
|
+
// Parse sanity: permission block must be present (catches a broken parse).
|
|
295
|
+
// opencode permission-checks MCP tool calls via the `permission` map (tool ids
|
|
296
|
+
// `<server>_<tool>`); we assert the canonical `permission: "deny"` form here.
|
|
297
|
+
const permission = opencode.permission ?? {}
|
|
298
|
+
expect(Object.keys(permission).length).toBeGreaterThan(0)
|
|
299
|
+
|
|
300
|
+
const violations: string[] = []
|
|
301
|
+
for (const cmd of SENSITIVE_MCP_COMMANDS) {
|
|
302
|
+
// (a) Primary gate: the command must NOT be in MCP_ALLOWLIST (source-gated out).
|
|
303
|
+
// If someone re-adds it to the allowlist, this catches the regression immediately.
|
|
304
|
+
if (MCP_ALLOWLIST.has(cmd)) {
|
|
305
|
+
violations.push(
|
|
306
|
+
`'${cmd}' is in SENSITIVE_MCP_COMMANDS AND in MCP_ALLOWLIST — source-gate regression: remove it from MCP_ALLOWLIST`,
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
// (b) Defense-in-depth backstop: the tool must still be denied in opencode.jsonc.
|
|
310
|
+
// This catches the case where MCP_ALLOWLIST is mistakenly re-expanded.
|
|
311
|
+
// The infra MCP server converts command names to tool names with underscores,
|
|
312
|
+
// and OpenCode prefixes with the server name, giving "infra_<underscored>".
|
|
313
|
+
const toolId = `infra_${cmd.replaceAll(' ', '_')}`
|
|
314
|
+
if (permission[toolId] !== 'deny') {
|
|
315
|
+
violations.push(
|
|
316
|
+
`${toolId}: not denied in opencode.jsonc permission (found: ${JSON.stringify(permission[toolId])}) — defense-in-depth backstop missing`,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
expect(
|
|
321
|
+
violations,
|
|
322
|
+
`Sensitive infra MCP tools must be source-gated (not in MCP_ALLOWLIST) and denied in opencode.jsonc:\n${violations.join('\n')}`,
|
|
323
|
+
).toEqual([])
|
|
324
|
+
})
|
|
245
325
|
})
|
|
246
326
|
|
|
247
327
|
// ---------------------------------------------------------------------------
|