@marcusrbrown/infra 0.9.7 → 0.9.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -717,3 +717,125 @@ describe('management auth probe (ban-awareness)', () => {
717
717
  expect(managementFetchUrls.length).toBe(1)
718
718
  })
719
719
  })
720
+
721
+ // ─── Ambient management key trusted-URL binding ───────────────────────────────
722
+
723
+ describe('cliproxyStatusAction — ambient key does not follow agent-supplied URLs', () => {
724
+ let originalEnv: Record<string, string | undefined>
725
+
726
+ beforeEach(() => {
727
+ originalEnv = {
728
+ CLIPROXY_URL: process.env.CLIPROXY_URL,
729
+ CLIPROXY_MANAGEMENT_KEY: process.env.CLIPROXY_MANAGEMENT_KEY,
730
+ }
731
+ process.env.CLIPROXY_MANAGEMENT_KEY = 'secret-ambient-key'
732
+ delete process.env.CLIPROXY_URL
733
+ })
734
+
735
+ afterEach(() => {
736
+ for (const [k, v] of Object.entries(originalEnv)) {
737
+ if (v === undefined) {
738
+ delete process.env[k]
739
+ } else {
740
+ process.env[k] = v
741
+ }
742
+ }
743
+ globalThis.fetch = originalFetch
744
+ })
745
+
746
+ it('does NOT send the ambient management key to a non-trusted --url override', async () => {
747
+ const capturedRequestsByHost: Record<string, string[]> = {}
748
+
749
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
750
+ const {hostname} = new URL(url)
751
+ const hdrs = init?.headers
752
+ const key = hdrs instanceof Headers ? hdrs.get('x-management-key') : null
753
+
754
+ if (!capturedRequestsByHost[hostname]) capturedRequestsByHost[hostname] = []
755
+ if (key) capturedRequestsByHost[hostname].push(key)
756
+
757
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
758
+ if (url.includes('/v0/management/'))
759
+ return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
760
+ return new Response('ok', {status: 200})
761
+ })
762
+
763
+ const {ctx} = createCapturedCtx()
764
+ try {
765
+ await cliproxyStatusAction({url: 'https://evil.example.com'}, ctx)
766
+ } catch {
767
+ // exit(1) expected or not — either way, we just care about key leakage
768
+ }
769
+
770
+ // The ambient secret key must NOT appear in any request to the evil host
771
+ expect(capturedRequestsByHost['evil.example.com'] ?? []).not.toContain('secret-ambient-key')
772
+ })
773
+
774
+ it('still uses the ambient key when --url matches the default trusted URL', async () => {
775
+ const capturedManagementKeys: string[] = []
776
+
777
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
778
+ const hdrs = init?.headers
779
+ const key = hdrs instanceof Headers ? hdrs.get('x-management-key') : null
780
+ if (url.includes('/v0/management/') && key) capturedManagementKeys.push(key)
781
+
782
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
783
+ if (url.includes('/v0/management/config'))
784
+ return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}})
785
+ if (url.includes('/v0/management/usage-queue'))
786
+ return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
787
+ if (url.includes('/v0/management/latest-version'))
788
+ return new Response(JSON.stringify({'latest-version': 'v1.0.0'}), {
789
+ status: 200,
790
+ headers: {'content-type': 'application/json'},
791
+ })
792
+ return new Response('ok', {status: 200})
793
+ })
794
+
795
+ const {ctx} = createCapturedCtx()
796
+ // Default URL is https://cliproxy.fro.bot — pass it explicitly
797
+ await cliproxyStatusAction({url: 'https://cliproxy.fro.bot'}, ctx)
798
+
799
+ expect(capturedManagementKeys).toContain('secret-ambient-key')
800
+ })
801
+
802
+ it('uses an explicit --key even when --url is a non-trusted host', async () => {
803
+ const capturedManagementKeys: string[] = []
804
+
805
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
806
+ const hdrs = init?.headers
807
+ const key = hdrs instanceof Headers ? hdrs.get('x-management-key') : null
808
+ if (url.includes('/v0/management/') && key) capturedManagementKeys.push(key)
809
+
810
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
811
+ if (url.includes('/v0/management/config'))
812
+ return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}})
813
+ if (url.includes('/v0/management/usage-queue'))
814
+ return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
815
+ if (url.includes('/v0/management/latest-version'))
816
+ return new Response(JSON.stringify({'latest-version': 'v1.0.0'}), {
817
+ status: 200,
818
+ headers: {'content-type': 'application/json'},
819
+ })
820
+ return new Response('ok', {status: 200})
821
+ })
822
+
823
+ const {ctx} = createCapturedCtx()
824
+ await cliproxyStatusAction({url: 'https://other-cliproxy.example.com', key: 'explicit-key'}, ctx)
825
+
826
+ expect(capturedManagementKeys).toContain('explicit-key')
827
+ })
828
+
829
+ it('falls through to the no-key warning path when URL is untrusted and no --key given', async () => {
830
+ globalThis.fetch = createFetchImplementation(async url => {
831
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
832
+ return new Response('[]', {status: 200})
833
+ })
834
+
835
+ const {ctx, captured} = createCapturedCtx()
836
+ await cliproxyStatusAction({url: 'https://other-cliproxy.example.com'}, ctx)
837
+
838
+ const output = [...captured.stdout, ...captured.stderr].join('\n')
839
+ expect(output).toMatch(/CLIPROXY_MANAGEMENT_KEY|no key|skipping/i)
840
+ })
841
+ })
@@ -385,7 +385,16 @@ export async function cliproxyStatusAction(options: StatusOptions, ctx: ActionCt
385
385
  try {
386
386
  const verbose = options.verbose === true
387
387
  const baseUrl = stripTrailingSlash(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
388
- const managementKey = options.key ?? process.env.CLIPROXY_MANAGEMENT_KEY
388
+
389
+ // Trusted URL: the canonical configured/default host, trailing-slash-normalized.
390
+ // Ambient env keys must ONLY follow to this trusted destination to prevent
391
+ // secret exfiltration when an agent passes an attacker-controlled --url.
392
+ const trustedUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
393
+ const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl
394
+
395
+ // An explicit --key is always honored (operator knows what they're doing).
396
+ // An ambient env key is only forwarded when the resolved baseUrl is trusted.
397
+ const managementKey = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_MANAGEMENT_KEY)
389
398
 
390
399
  ctx.console.log('CLIProxyAPI status')
391
400
  ctx.console.log('')
@@ -58,7 +58,7 @@ describe('validateGatewayHost', () => {
58
58
 
59
59
  // ── Error message sanitization ──────────────────────────────────────────────
60
60
 
61
- it('truncates the invalid value in the error message to ~30 chars', () => {
61
+ it('omits the rejected value entirely from the error message', () => {
62
62
  const longMalicious = `-oProxyCommand=${'A'.repeat(100)}`
63
63
  let message = ''
64
64
  try {
@@ -66,8 +66,24 @@ describe('validateGatewayHost', () => {
66
66
  } catch (error) {
67
67
  message = error instanceof Error ? error.message : String(error)
68
68
  }
69
- // The excerpt in the message should not exceed 30 chars of the original value
69
+ // The value is never echoed (it may be a misdirected secret) — only the constraint is shown.
70
70
  expect(message).toContain('Invalid GATEWAY_HOST')
71
- expect(message.length).toBeLessThan(longMalicious.length + 50)
71
+ expect(message).not.toContain('ProxyCommand')
72
+ expect(message).not.toContain('AAAA')
73
+ })
74
+
75
+ // ── Secret value redaction ───────────────────────────────────────────────────
76
+
77
+ it('does not echo the rejected value in the error message', () => {
78
+ const secretValue = 'AWS_SECRET_KEY=AKIA1234567890EXAMPLE'
79
+ let message = ''
80
+ try {
81
+ validateGatewayHost(secretValue)
82
+ } catch (error) {
83
+ message = error instanceof Error ? error.message : String(error)
84
+ }
85
+ expect(message).toContain('Invalid GATEWAY_HOST')
86
+ expect(message).not.toContain('AKIA1234567890EXAMPLE')
87
+ expect(message).not.toContain('AWS_SECRET_KEY')
72
88
  })
73
89
  })
@@ -13,7 +13,8 @@ const VALID_HOST_RE = /^[a-z\d][a-z\d.\-]*$/i
13
13
  * Rejects: empty strings, values starting with `-`, and anything containing
14
14
  * characters outside `[A-Za-z0-9.-]`.
15
15
  *
16
- * @throws {Error} with a sanitized excerpt of the invalid value.
16
+ * @throws {Error} when empty or not a valid hostname. The rejected value is
17
+ * never included in the message (it may be a misdirected secret).
17
18
  * @returns The validated host string (unchanged).
18
19
  */
19
20
  export function validateGatewayHost(host: string): string {
@@ -22,9 +23,10 @@ export function validateGatewayHost(host: string): string {
22
23
  }
23
24
 
24
25
  if (!VALID_HOST_RE.test(host)) {
25
- // Truncate to 30 chars and strip non-printable bytes before echoing back
26
- const excerpt = host.slice(0, 30).replaceAll(/[^\u0020-\u007E]/g, '?')
27
- throw new Error(`Invalid GATEWAY_HOST: "${excerpt}" — must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`}`)
26
+ // Do NOT echo the value — it may be a secret that was redirected via --key
27
+ throw new Error(
28
+ `Invalid GATEWAY_HOST: value is not a valid hostname (must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`})`,
29
+ )
28
30
  }
29
31
 
30
32
  return host
@@ -481,3 +481,70 @@ describe('gatewayStatusAction — ctx capture (Tier-2)', () => {
481
481
  expect(captured.exit?.code).toBe(1)
482
482
  })
483
483
  })
484
+
485
+ // ─── SSH error redaction — host value must not leak in returned error strings ──
486
+
487
+ describe('getGatewayComposeStatus — SSH error redaction', () => {
488
+ it('does not include the resolved host value in the error when SSH fails', async () => {
489
+ const {getGatewayComposeStatus} = await import('./status')
490
+ const secretLookingHost = 'TOPSECRETHOSTNAME'
491
+
492
+ const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
493
+ const encoder = new TextEncoder()
494
+ return {
495
+ stdout: new ReadableStream({
496
+ start(c) {
497
+ c.enqueue(encoder.encode(''))
498
+ c.close()
499
+ },
500
+ }),
501
+ stderr: new ReadableStream({
502
+ start(c) {
503
+ c.enqueue(encoder.encode(`ssh: Could not resolve hostname ${secretLookingHost}: Name or service not known`))
504
+ c.close()
505
+ },
506
+ }),
507
+ exited: Promise.resolve(255),
508
+ }
509
+ }
510
+
511
+ const result = await getGatewayComposeStatus(secretLookingHost, mockSpawn)
512
+
513
+ expect(result.ok).toBe(false)
514
+ expect(result.error).toBeDefined()
515
+ expect(result.error).not.toContain(secretLookingHost)
516
+ })
517
+
518
+ it('does not leak the host even when OpenSSH lowercases it in stderr', async () => {
519
+ const {getGatewayComposeStatus} = await import('./status')
520
+ // A misdirected secret-shaped value passes the hostname regex and becomes the host;
521
+ // OpenSSH lowercases it in the "Could not resolve hostname" stderr line.
522
+ const secretHost = 'AKIAIOSFODNN7EXAMPLE'
523
+
524
+ const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
525
+ const encoder = new TextEncoder()
526
+ return {
527
+ stdout: new ReadableStream({
528
+ start(c) {
529
+ c.close()
530
+ },
531
+ }),
532
+ stderr: new ReadableStream({
533
+ start(c) {
534
+ c.enqueue(
535
+ encoder.encode(`ssh: Could not resolve hostname ${secretHost.toLowerCase()}: Name or service not known`),
536
+ )
537
+ c.close()
538
+ },
539
+ }),
540
+ exited: Promise.resolve(255),
541
+ }
542
+ }
543
+
544
+ const result = await getGatewayComposeStatus(secretHost, mockSpawn)
545
+
546
+ expect(result.ok).toBe(false)
547
+ expect(result.error).not.toContain(secretHost)
548
+ expect(result.error).not.toContain(secretHost.toLowerCase())
549
+ })
550
+ })
@@ -1,11 +1,10 @@
1
1
  import type {goke} from 'goke'
2
2
  import type {ActionCtx} from '../../lib/action-ctx'
3
3
  import type {StatusSummary} from '../status'
4
-
5
4
  import {z} from 'zod'
6
5
 
6
+ import {redactHost} from '../../lib/redact'
7
7
  // ─── Minimal ctx interface (subset of GokeExecutionContext used by this action) ─
8
-
9
8
  import {validateGatewayHost} from './host'
10
9
 
11
10
  declare const process: {
@@ -128,10 +127,11 @@ export async function getGatewayComposeStatus(
128
127
  ])
129
128
 
130
129
  if (exitCode !== 0) {
130
+ const redacted = redactHost(stderrText.trim(), host) || 'unknown error'
131
131
  return {
132
132
  ok: false,
133
133
  services: [],
134
- error: `SSH command failed (exit ${exitCode}): ${stderrText.trim() || 'unknown error'}`,
134
+ error: `SSH command failed (exit ${exitCode}): ${redacted}`,
135
135
  }
136
136
  }
137
137
 
@@ -140,7 +140,11 @@ export async function getGatewayComposeStatus(
140
140
  try {
141
141
  entries = parseComposePsOutput(stdoutText)
142
142
  } catch {
143
- return {ok: false, services: [], error: `Failed to parse docker compose ps output: ${stdoutText.slice(0, 200)}`}
143
+ return {
144
+ ok: false,
145
+ services: [],
146
+ error: `Failed to parse docker compose ps output: ${redactHost(stdoutText.slice(0, 200), host)}`,
147
+ }
144
148
  }
145
149
 
146
150
  const services = parseComposePs(entries)
@@ -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
- /** Derived from the production MCP_ALLOWLIST — spaces replaced by underscores. */
45
- const EXPECTED_TOOLS = [...MCP_ALLOWLIST].map(name => name.replaceAll(' ', '_')).sort()
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
- /** The 9 CLI-only commands that must NOT appear in the MCP tool list. */
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('cli-only commands are absent from the tool list', async () => {
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
  })
@@ -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',
@@ -48,7 +48,7 @@ describe('validateUmamiHost', () => {
48
48
  expect(() => validateUmamiHost('')).toThrow('Invalid UMAMI_DOMAIN')
49
49
  })
50
50
 
51
- it('truncates the invalid value in the error message to ~30 chars', () => {
51
+ it('omits the rejected value entirely from the error message', () => {
52
52
  const longMalicious = `-oProxyCommand=${'A'.repeat(100)}`
53
53
  let message = ''
54
54
  try {
@@ -56,7 +56,24 @@ describe('validateUmamiHost', () => {
56
56
  } catch (error) {
57
57
  message = error instanceof Error ? error.message : String(error)
58
58
  }
59
+ // The value is never echoed (it may be a misdirected secret) — only the constraint is shown.
59
60
  expect(message).toContain('Invalid UMAMI_DOMAIN')
60
- expect(message.length).toBeLessThan(longMalicious.length + 50)
61
+ expect(message).not.toContain('ProxyCommand')
62
+ expect(message).not.toContain('AAAA')
63
+ })
64
+
65
+ // ── Secret value redaction ───────────────────────────────────────────────────
66
+
67
+ it('does not echo the rejected value in the error message', () => {
68
+ const secretValue = 'AWS_SECRET_KEY=AKIA1234567890EXAMPLE'
69
+ let message = ''
70
+ try {
71
+ validateUmamiHost(secretValue)
72
+ } catch (error) {
73
+ message = error instanceof Error ? error.message : String(error)
74
+ }
75
+ expect(message).toContain('Invalid UMAMI_DOMAIN')
76
+ expect(message).not.toContain('AKIA1234567890EXAMPLE')
77
+ expect(message).not.toContain('AWS_SECRET_KEY')
61
78
  })
62
79
  })
@@ -13,7 +13,8 @@ const VALID_HOST_RE = /^[a-z\d][a-z\d.\-]*$/i
13
13
  * Rejects: empty strings, values starting with `-`, and anything containing
14
14
  * characters outside `[A-Za-z0-9.-]`.
15
15
  *
16
- * @throws {Error} with a sanitized excerpt of the invalid value.
16
+ * @throws {Error} when empty or not a valid hostname. The rejected value is
17
+ * never included in the message (it may be a misdirected secret).
17
18
  * @returns The validated host string (unchanged).
18
19
  */
19
20
  export function validateUmamiHost(host: string): string {
@@ -22,9 +23,10 @@ export function validateUmamiHost(host: string): string {
22
23
  }
23
24
 
24
25
  if (!VALID_HOST_RE.test(host)) {
25
- // Truncate to 30 chars and strip non-printable bytes before echoing back
26
- const excerpt = host.slice(0, 30).replaceAll(/[^\u0020-\u007E]/g, '?')
27
- throw new Error(`Invalid UMAMI_DOMAIN: "${excerpt}" — must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`}`)
26
+ // Do NOT echo the value — it may be a secret that was redirected via --key
27
+ throw new Error(
28
+ `Invalid UMAMI_DOMAIN: value is not a valid hostname (must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`})`,
29
+ )
28
30
  }
29
31
 
30
32
  return host
@@ -385,3 +385,68 @@ describe('status command', () => {
385
385
  expect(captured.exit).toBeNull()
386
386
  })
387
387
  })
388
+
389
+ // ─── SSH error redaction — host value must not leak in returned error strings ──
390
+
391
+ describe('getUmamiComposeStatus — SSH error redaction', () => {
392
+ it('does not include the resolved host value in the error when SSH fails', async () => {
393
+ const {getUmamiComposeStatus} = await import('./status')
394
+ const secretLookingHost = 'TOPSECRETHOSTNAME'
395
+
396
+ const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
397
+ const encoder = new TextEncoder()
398
+ return {
399
+ stdout: new ReadableStream({
400
+ start(c) {
401
+ c.enqueue(encoder.encode(''))
402
+ c.close()
403
+ },
404
+ }),
405
+ stderr: new ReadableStream({
406
+ start(c) {
407
+ c.enqueue(encoder.encode(`ssh: Could not resolve hostname ${secretLookingHost}: Name or service not known`))
408
+ c.close()
409
+ },
410
+ }),
411
+ exited: Promise.resolve(255),
412
+ }
413
+ }
414
+
415
+ const result = await getUmamiComposeStatus(secretLookingHost, mockSpawn)
416
+
417
+ expect(result.ok).toBe(false)
418
+ expect(result.error).toBeDefined()
419
+ expect(result.error).not.toContain(secretLookingHost)
420
+ })
421
+
422
+ it('does not leak the host even when OpenSSH lowercases it in stderr', async () => {
423
+ const {getUmamiComposeStatus} = await import('./status')
424
+ const secretHost = 'AKIAIOSFODNN7EXAMPLE'
425
+
426
+ const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
427
+ const encoder = new TextEncoder()
428
+ return {
429
+ stdout: new ReadableStream({
430
+ start(c) {
431
+ c.close()
432
+ },
433
+ }),
434
+ stderr: new ReadableStream({
435
+ start(c) {
436
+ c.enqueue(
437
+ encoder.encode(`ssh: Could not resolve hostname ${secretHost.toLowerCase()}: Name or service not known`),
438
+ )
439
+ c.close()
440
+ },
441
+ }),
442
+ exited: Promise.resolve(255),
443
+ }
444
+ }
445
+
446
+ const result = await getUmamiComposeStatus(secretHost, mockSpawn)
447
+
448
+ expect(result.ok).toBe(false)
449
+ expect(result.error).not.toContain(secretHost)
450
+ expect(result.error).not.toContain(secretHost.toLowerCase())
451
+ })
452
+ })
@@ -1,9 +1,9 @@
1
1
  import type {goke} from 'goke'
2
2
  import type {ActionCtx} from '../../lib/action-ctx'
3
3
  import type {StatusSummary} from '../status'
4
-
5
4
  import {z} from 'zod'
6
5
 
6
+ import {redactHost} from '../../lib/redact'
7
7
  import {validateUmamiHost} from './host'
8
8
 
9
9
  declare const process: {
@@ -124,7 +124,7 @@ export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defau
124
124
  return {
125
125
  ok: false,
126
126
  services: [],
127
- error: `SSH command failed (exit ${exitCode}): ${stderrText.trim() || 'unknown error'}`,
127
+ error: `SSH command failed (exit ${exitCode}): ${redactHost(stderrText.trim(), host) || 'unknown error'}`,
128
128
  }
129
129
  }
130
130
 
@@ -133,7 +133,11 @@ export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defau
133
133
  try {
134
134
  entries = parseComposePsOutput(stdoutText)
135
135
  } catch {
136
- return {ok: false, services: [], error: `Failed to parse docker compose ps output: ${stdoutText.slice(0, 200)}`}
136
+ return {
137
+ ok: false,
138
+ services: [],
139
+ error: `Failed to parse docker compose ps output: ${redactHost(stdoutText.slice(0, 200), host)}`,
140
+ }
137
141
  }
138
142
 
139
143
  const services = parseComposePs(entries)
@@ -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
  // ---------------------------------------------------------------------------
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Redact every occurrence of a host value from text, case-insensitively.
3
+ *
4
+ * Used to scrub a resolved SSH host out of error output before it surfaces to an
5
+ * MCP consumer. The host can be a misdirected secret (an agent may point the
6
+ * `--key` env-var selector at, e.g., `AWS_SECRET_ACCESS_KEY`), so its value must
7
+ * never appear in returned errors.
8
+ *
9
+ * Two subtleties this handles that a plain `replaceAll(host, ...)` does not:
10
+ * 1. OpenSSH lowercases the hostname in "Could not resolve hostname <name>"
11
+ * stderr, so the match must be case-insensitive.
12
+ * 2. Validated hosts contain `.` and `-`, which are regex-significant, so the
13
+ * host is escaped before building the matcher (no over-redaction).
14
+ *
15
+ * An empty/falsy host is a no-op (an empty matcher would match everywhere).
16
+ */
17
+ export function redactHost(text: string, host: string): string {
18
+ if (!host) return text
19
+ const escaped = host.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
20
+ return text.replaceAll(new RegExp(escaped, 'gi'), '<host>')
21
+ }