@open-mercato/ai-assistant 0.6.5-develop.5382.1.f542de69af → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +12 -0
  3. package/dist/modules/ai_assistant/cli.js +14 -6
  4. package/dist/modules/ai_assistant/cli.js.map +2 -2
  5. package/dist/modules/ai_assistant/data/entities.js +4 -0
  6. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  7. package/dist/modules/ai_assistant/lib/attachment-parts.js +10 -3
  8. package/dist/modules/ai_assistant/lib/attachment-parts.js.map +2 -2
  9. package/dist/modules/ai_assistant/lib/client-factory.js +0 -8
  10. package/dist/modules/ai_assistant/lib/client-factory.js.map +2 -2
  11. package/dist/modules/ai_assistant/lib/codemode-tools.js +26 -0
  12. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
  13. package/dist/modules/ai_assistant/lib/generated-registry-loader.js +23 -3
  14. package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
  15. package/dist/modules/ai_assistant/lib/mcp-client.js +2 -4
  16. package/dist/modules/ai_assistant/lib/mcp-client.js.map +2 -2
  17. package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js +71 -0
  18. package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js.map +7 -0
  19. package/dist/modules/ai_assistant/lib/mcp-dev-server.js +1 -30
  20. package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
  21. package/dist/modules/ai_assistant/lib/mcp-server.js +29 -23
  22. package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
  23. package/dist/modules/ai_assistant/setup.js +9 -2
  24. package/dist/modules/ai_assistant/setup.js.map +2 -2
  25. package/package.json +11 -12
  26. package/src/modules/ai_assistant/__tests__/cli-mcp-serve-key.test.ts +63 -0
  27. package/src/modules/ai_assistant/__tests__/setup-schedules.test.ts +33 -0
  28. package/src/modules/ai_assistant/cli.ts +18 -5
  29. package/src/modules/ai_assistant/data/__tests__/schema-unique-indexes.test.ts +39 -0
  30. package/src/modules/ai_assistant/data/entities.ts +5 -0
  31. package/src/modules/ai_assistant/lib/__tests__/attachment-parts.test.ts +112 -0
  32. package/src/modules/ai_assistant/lib/__tests__/client-factory.test.ts +42 -0
  33. package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +107 -0
  34. package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +30 -3
  35. package/src/modules/ai_assistant/lib/__tests__/mcp-client.test.ts +20 -1
  36. package/src/modules/ai_assistant/lib/__tests__/mcp-dev-key-resolution.test.ts +92 -0
  37. package/src/modules/ai_assistant/lib/__tests__/mcp-server-auth-fail-closed.test.ts +133 -0
  38. package/src/modules/ai_assistant/lib/attachment-parts.ts +26 -5
  39. package/src/modules/ai_assistant/lib/client-factory.ts +3 -9
  40. package/src/modules/ai_assistant/lib/codemode-tools.ts +49 -0
  41. package/src/modules/ai_assistant/lib/generated-registry-loader.ts +35 -3
  42. package/src/modules/ai_assistant/lib/mcp-client.ts +9 -5
  43. package/src/modules/ai_assistant/lib/mcp-dev-key-resolution.ts +107 -0
  44. package/src/modules/ai_assistant/lib/mcp-dev-server.ts +1 -36
  45. package/src/modules/ai_assistant/lib/mcp-server.ts +47 -33
  46. package/src/modules/ai_assistant/lib/types.ts +7 -0
  47. package/src/modules/ai_assistant/setup.ts +11 -2
@@ -0,0 +1,63 @@
1
+ import type { ModuleCli } from '@open-mercato/shared/modules/registry'
2
+ import cliCommands from '../cli'
3
+
4
+ const mockRunMcpServer = jest.fn(async () => undefined)
5
+
6
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
7
+ createRequestContainer: jest.fn(async () => ({})),
8
+ getDiRegistrars: jest.fn(() => []),
9
+ }))
10
+
11
+ jest.mock('../lib/mcp-server', () => ({
12
+ runMcpServer: (...args: unknown[]) => mockRunMcpServer(...args),
13
+ }))
14
+
15
+ function getMcpServe(): ModuleCli {
16
+ const cmd = (cliCommands as ModuleCli[]).find((c) => c.command === 'mcp:serve')
17
+ if (!cmd) throw new Error('mcp:serve command not found')
18
+ return cmd
19
+ }
20
+
21
+ describe('mcp:serve API key resolution (issue #2669)', () => {
22
+ const originalEnv = process.env.OPEN_MERCATO_API_KEY
23
+
24
+ beforeEach(() => {
25
+ jest.clearAllMocks()
26
+ delete process.env.OPEN_MERCATO_API_KEY
27
+ })
28
+
29
+ afterEach(() => {
30
+ if (originalEnv === undefined) delete process.env.OPEN_MERCATO_API_KEY
31
+ else process.env.OPEN_MERCATO_API_KEY = originalEnv
32
+ })
33
+
34
+ it('reads the key from OPEN_MERCATO_API_KEY when --api-key is absent', async () => {
35
+ process.env.OPEN_MERCATO_API_KEY = 'omk_env.secret'
36
+
37
+ await getMcpServe().run([])
38
+
39
+ expect(mockRunMcpServer).toHaveBeenCalledTimes(1)
40
+ expect(mockRunMcpServer).toHaveBeenCalledWith(
41
+ expect.objectContaining({ apiKeySecret: 'omk_env.secret' }),
42
+ )
43
+ })
44
+
45
+ it('still accepts the --api-key flag and prefers it over the env var', async () => {
46
+ process.env.OPEN_MERCATO_API_KEY = 'omk_env.secret'
47
+
48
+ await getMcpServe().run(['--api-key', 'omk_flag.secret'])
49
+
50
+ expect(mockRunMcpServer).toHaveBeenCalledWith(
51
+ expect.objectContaining({ apiKeySecret: 'omk_flag.secret' }),
52
+ )
53
+ })
54
+
55
+ it('prints usage and does not start the server when neither key nor tenant is provided', async () => {
56
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
57
+
58
+ await getMcpServe().run([])
59
+
60
+ expect(mockRunMcpServer).not.toHaveBeenCalled()
61
+ errorSpy.mockRestore()
62
+ })
63
+ })
@@ -0,0 +1,33 @@
1
+ import { setup } from '../setup'
2
+
3
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
4
+
5
+ function makeContainer() {
6
+ const register = jest.fn().mockResolvedValue(undefined)
7
+ return {
8
+ register,
9
+ container: {
10
+ resolve(name: string) {
11
+ if (name !== 'schedulerService') throw new Error(`unexpected registration: ${name}`)
12
+ return { register }
13
+ },
14
+ },
15
+ }
16
+ }
17
+
18
+ describe('ai_assistant setup schedules', () => {
19
+ it('registers module schedules with uuid ids accepted by the scheduler table', async () => {
20
+ const { container, register } = makeContainer()
21
+
22
+ await setup.seedDefaults?.({ container } as never)
23
+
24
+ expect(register).toHaveBeenCalledTimes(2)
25
+ const ids = register.mock.calls.map(([registration]) => registration.id)
26
+
27
+ expect(ids).toHaveLength(2)
28
+ expect(new Set(ids).size).toBe(2)
29
+ expect(ids).not.toContain('ai_assistant:pending-action-cleanup')
30
+ expect(ids).not.toContain('ai_assistant:token-usage-prune')
31
+ expect(ids.every((id) => UUID_RE.test(id))).toBe(true)
32
+ })
33
+ })
@@ -47,30 +47,42 @@ const mcpServe: ModuleCli = {
47
47
  command: 'mcp:serve',
48
48
  async run(rest) {
49
49
  const args = parseArgs(rest)
50
- const apiKey = String(args['api-key'] ?? args.apiKey ?? '') || null
50
+ // Prefer the OPEN_MERCATO_API_KEY env var so the secret never has to be
51
+ // placed on the command line (argv is world-readable via ps / /proc).
52
+ // The --api-key flag stays supported for backward compatibility.
53
+ const apiKey = String(args['api-key'] ?? args.apiKey ?? '') || process.env.OPEN_MERCATO_API_KEY || null
51
54
  const tenantId = String(args.tenant ?? args.tenantId ?? '') || null
52
55
  const organizationId = String(args.org ?? args.organizationId ?? '') || null
53
56
  const userId = String(args.user ?? args.userId ?? '') || null
54
57
  const debug = args.debug === true || args.debug === 'true'
58
+ const allowUnauthenticatedSuperadmin =
59
+ args['allow-unauthenticated-superadmin'] === true ||
60
+ args['allow-unauthenticated-superadmin'] === 'true'
55
61
 
56
62
  // Either API key or tenant is required
57
63
  if (!apiKey && !tenantId) {
58
64
  console.error('Usage: mercato ai_assistant mcp:serve [options]')
59
65
  console.error('')
60
66
  console.error('Authentication (choose one):')
61
- console.error(' --api-key <secret> API key secret for authentication (recommended)')
62
- console.error(' --tenant <id> Tenant ID (for manual context)')
67
+ console.error(' OPEN_MERCATO_API_KEY env API key secret (recommended — keeps the secret off argv)')
68
+ console.error(' --api-key <secret> API key secret for authentication (visible in process listings)')
69
+ console.error(' --tenant <id> Tenant ID (for manual context)')
63
70
  console.error('')
64
71
  console.error('Options (with --tenant):')
65
72
  console.error(' --org <id> Organization ID (optional)')
66
- console.error(' --user <id> User ID for ACL (optional, uses superadmin if not set)')
73
+ console.error(' --user <id> User ID for ACL (required unless --allow-unauthenticated-superadmin is set)')
67
74
  console.error('')
68
75
  console.error('Common options:')
69
76
  console.error(' --debug Enable debug logging')
77
+ console.error(' --allow-unauthenticated-superadmin')
78
+ console.error(' DEV/TEST ONLY: run as superadmin with no per-user ACL when')
79
+ console.error(' no --user (and no --api-key) is supplied. Never use in production.')
70
80
  console.error('')
71
81
  console.error('Examples:')
82
+ console.error(' OPEN_MERCATO_API_KEY=omk_xxxx.yyyy... mercato ai_assistant mcp:serve')
72
83
  console.error(' mercato ai_assistant mcp:serve --api-key omk_xxxx.yyyy...')
73
- console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000')
84
+ console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000 --user <user-id>')
85
+ console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000 --allow-unauthenticated-superadmin')
74
86
  return
75
87
  }
76
88
 
@@ -102,6 +114,7 @@ const mcpServe: ModuleCli = {
102
114
  organizationId,
103
115
  userId,
104
116
  },
117
+ allowUnauthenticatedSuperadmin,
105
118
  })
106
119
  }
107
120
  },
@@ -38,6 +38,45 @@ describe('AI assistant nullable organization unique indexes', () => {
38
38
  },
39
39
  )
40
40
 
41
+ it('declares every snapshot expression index on the participants entity (no snapshot/entity drift)', () => {
42
+ const snapshot = JSON.parse(
43
+ readFileSync(
44
+ new URL('../../migrations/.snapshot-open-mercato.json', import.meta.url),
45
+ 'utf8',
46
+ ),
47
+ ) as {
48
+ tables: Array<{ name: string; indexes?: Array<{ keyName: string; expression?: string }> }>
49
+ }
50
+
51
+ const participants = snapshot.tables.find(
52
+ (table) => table.name === 'ai_chat_conversation_participants',
53
+ )
54
+ expect(participants).toBeDefined()
55
+
56
+ const entitySource = readFileSync(
57
+ new URL('../entities.ts', import.meta.url),
58
+ 'utf8',
59
+ )
60
+
61
+ const expressionIndexes = (participants?.indexes ?? []).filter((index) => index.expression)
62
+ expect(expressionIndexes.length).toBeGreaterThan(0)
63
+
64
+ for (const index of expressionIndexes) {
65
+ expect(entitySource).toContain(index.keyName)
66
+ }
67
+ })
68
+
69
+ it('keeps the active-participants partial index declared on the entity', () => {
70
+ const entitySource = readFileSync(
71
+ new URL('../entities.ts', import.meta.url),
72
+ 'utf8',
73
+ )
74
+
75
+ expect(entitySource).toContain(
76
+ 'create index "ai_chat_conv_participants_active_conv_user_idx" on "ai_chat_conversation_participants" ("tenant_id", "organization_id", "conversation_id", "user_id") where "deleted_at" is null',
77
+ )
78
+ })
79
+
41
80
  it('keeps the module snapshot aligned with partial unique indexes', () => {
42
81
  const snapshot = JSON.parse(
43
82
  readFileSync(
@@ -789,6 +789,11 @@ export class AiChatConversation {
789
789
  expression:
790
790
  'create unique index "ai_chat_conv_participants_tenant_conv_user_null_org_uq" on "ai_chat_conversation_participants" ("tenant_id", "conversation_id", "user_id") where "organization_id" is null',
791
791
  })
792
+ @Index({
793
+ name: 'ai_chat_conv_participants_active_conv_user_idx',
794
+ expression:
795
+ 'create index "ai_chat_conv_participants_active_conv_user_idx" on "ai_chat_conversation_participants" ("tenant_id", "organization_id", "conversation_id", "user_id") where "deleted_at" is null',
796
+ })
792
797
  @Index({
793
798
  name: 'ai_chat_conv_participants_tenant_org_user_conv_idx',
794
799
  properties: ['tenantId', 'organizationId', 'userId', 'conversationId'],
@@ -349,6 +349,118 @@ describe('resolveAttachmentParts — tenant / org scope enforcement', () => {
349
349
  expect(parts).toEqual([])
350
350
  expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('not found'))
351
351
  })
352
+
353
+ // Regression for #2663 — null-scoped rows used to bypass the tenant check via
354
+ // a truthiness short-circuit and leak into the caller's LLM context.
355
+ it('drops a null-tenant (global) record for a non super-admin caller', async () => {
356
+ findOneWithDecryptionMock.mockResolvedValueOnce(
357
+ makeRow({
358
+ id: 'null-tenant-1',
359
+ tenantId: null,
360
+ organizationId: null,
361
+ mimeType: 'image/png',
362
+ fileSize: 64,
363
+ content: 'secret OCR text',
364
+ }),
365
+ )
366
+
367
+ const parts = await resolveAttachmentParts({
368
+ attachmentIds: ['null-tenant-1'],
369
+ authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
370
+ container: makeContainer(),
371
+ })
372
+
373
+ expect(parts).toEqual([])
374
+ expect(fsReadFileMock).not.toHaveBeenCalled()
375
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('out of scope'))
376
+ })
377
+
378
+ // Regression for #2663 — `tenantId='X', organizationId=null` used to be
379
+ // readable by every org within tenant X.
380
+ it('drops a null-org record in the caller tenant for an org-scoped caller', async () => {
381
+ findOneWithDecryptionMock.mockResolvedValueOnce(
382
+ makeRow({
383
+ id: 'null-org-1',
384
+ tenantId: 'tenant-1',
385
+ organizationId: null,
386
+ mimeType: 'image/png',
387
+ fileSize: 64,
388
+ }),
389
+ )
390
+
391
+ const parts = await resolveAttachmentParts({
392
+ attachmentIds: ['null-org-1'],
393
+ authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
394
+ container: makeContainer(),
395
+ })
396
+
397
+ expect(parts).toEqual([])
398
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('out of scope'))
399
+ })
400
+
401
+ it('lets a tenant-wide caller (null org) read any org within its tenant', async () => {
402
+ findOneWithDecryptionMock.mockResolvedValueOnce(
403
+ makeRow({
404
+ id: 'same-tenant-other-org',
405
+ tenantId: 'tenant-1',
406
+ organizationId: 'org-OTHER',
407
+ mimeType: 'application/zip',
408
+ fileSize: 64,
409
+ content: null,
410
+ }),
411
+ )
412
+
413
+ const parts = await resolveAttachmentParts({
414
+ attachmentIds: ['same-tenant-other-org'],
415
+ authContext: makeAuth({ tenantId: 'tenant-1', organizationId: null }),
416
+ container: makeContainer(),
417
+ })
418
+
419
+ expect(parts).toHaveLength(1)
420
+ expect(parts[0].attachmentId).toBe('same-tenant-other-org')
421
+ })
422
+
423
+ it('scopes the ORM query by tenant + org for a non super-admin caller', async () => {
424
+ findOneWithDecryptionMock.mockResolvedValueOnce(makeRow({ id: 'att-scope-1', fileSize: 0 }))
425
+
426
+ await resolveAttachmentParts({
427
+ attachmentIds: ['att-scope-1'],
428
+ authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
429
+ container: makeContainer(),
430
+ })
431
+
432
+ const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
433
+ expect(whereArg).toEqual({ id: 'att-scope-1', tenantId: 'tenant-1', organizationId: 'org-1' })
434
+ })
435
+
436
+ it('omits org from the ORM query for a tenant-wide caller (null org)', async () => {
437
+ findOneWithDecryptionMock.mockResolvedValueOnce(makeRow({ id: 'att-scope-2', fileSize: 0 }))
438
+
439
+ await resolveAttachmentParts({
440
+ attachmentIds: ['att-scope-2'],
441
+ authContext: makeAuth({ tenantId: 'tenant-1', organizationId: null }),
442
+ container: makeContainer(),
443
+ })
444
+
445
+ const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
446
+ expect(whereArg).toEqual({ id: 'att-scope-2', tenantId: 'tenant-1' })
447
+ expect(whereArg).not.toHaveProperty('organizationId')
448
+ })
449
+
450
+ it('does not scope the ORM query for super-admin callers', async () => {
451
+ findOneWithDecryptionMock.mockResolvedValueOnce(
452
+ makeRow({ id: 'att-scope-3', tenantId: 'tenant-OTHER', organizationId: 'org-OTHER', fileSize: 0 }),
453
+ )
454
+
455
+ await resolveAttachmentParts({
456
+ attachmentIds: ['att-scope-3'],
457
+ authContext: makeAuth({ isSuperAdmin: true }),
458
+ container: makeContainer(),
459
+ })
460
+
461
+ const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
462
+ expect(whereArg).toEqual({ id: 'att-scope-3' })
463
+ })
352
464
  })
353
465
 
354
466
  describe('resolveAttachmentParts — unavailable service graceful skip', () => {
@@ -0,0 +1,42 @@
1
+ import { createMcpClient } from '../client-factory'
2
+
3
+ const mockConnect = jest.fn(async () => ({ listTools: jest.fn(), callTool: jest.fn(), close: jest.fn() }))
4
+
5
+ jest.mock('../mcp-client', () => ({
6
+ McpClient: {
7
+ connect: (...args: unknown[]) => mockConnect(...args),
8
+ },
9
+ }))
10
+
11
+ describe('createMcpClient stdio mode (issue #2669)', () => {
12
+ beforeEach(() => {
13
+ jest.clearAllMocks()
14
+ })
15
+
16
+ it('never places the API key on the default argv', async () => {
17
+ await createMcpClient({ mode: 'stdio', apiKeySecret: 'omk_secret.value' })
18
+
19
+ expect(mockConnect).toHaveBeenCalledTimes(1)
20
+ const options = mockConnect.mock.calls[0][0] as { apiKeySecret: string; args?: string[] }
21
+
22
+ // The secret is forwarded to McpClient.connect, which delivers it via env.
23
+ expect(options.apiKeySecret).toBe('omk_secret.value')
24
+
25
+ // The factory must not build default args that embed the secret. Leaving
26
+ // args undefined lets connectStdio use its secret-free default.
27
+ expect(options.args).toBeUndefined()
28
+ expect(JSON.stringify(options.args ?? [])).not.toContain('--api-key')
29
+ expect(JSON.stringify(options.args ?? [])).not.toContain('omk_secret.value')
30
+ })
31
+
32
+ it('honors explicitly provided stdio args without injecting the secret', async () => {
33
+ await createMcpClient({
34
+ mode: 'stdio',
35
+ apiKeySecret: 'omk_secret.value',
36
+ stdioArgs: ['node', 'custom-server.js'],
37
+ })
38
+
39
+ const options = mockConnect.mock.calls[0][0] as { args?: string[] }
40
+ expect(options.args).toEqual(['node', 'custom-server.js'])
41
+ })
42
+ })
@@ -7,6 +7,7 @@ import {
7
7
  CODE_MODE_MAX_MUTATION_CALLS,
8
8
  CODE_MODE_REQUIRED_FEATURES,
9
9
  createApiRequestFn,
10
+ isUnsafeApiRequestPath,
10
11
  isUnsafeHttpMethod,
11
12
  matchApiEndpointPath,
12
13
  } from '../codemode-tools'
@@ -287,6 +288,112 @@ describe('authorizeCodeModeApiRequest', () => {
287
288
  })
288
289
  })
289
290
 
291
+ describe('path traversal hardening (issue #2667)', () => {
292
+ describe('isUnsafeApiRequestPath', () => {
293
+ it.each([
294
+ '/api/foo/../admin',
295
+ '/api/foo/..',
296
+ '/api/foo/./admin',
297
+ '/api/.',
298
+ '/api/foo/%2e%2e/admin',
299
+ '/api/foo/%2E%2E/admin',
300
+ '/api/foo/%2e./admin',
301
+ '/api/foo/.%2e/admin',
302
+ '/api/foo/%2e/admin',
303
+ '/api/foo\\admin',
304
+ '/api/foo%2fadmin',
305
+ '/api/foo%2Fadmin',
306
+ '/api/foo%5cadmin',
307
+ 'foo/../admin',
308
+ '/api/foo/../admin?expand=1',
309
+ // Raw ASCII tab/newline/CR are stripped by the WHATWG URL parser before
310
+ // the fetch, so `.<ctrl>.` collapses to `..` on the wire.
311
+ '/api/foo/.\t./admin',
312
+ '/api/foo/.\n./admin',
313
+ '/api/foo/.\r./admin',
314
+ '/api/foo/\t../admin',
315
+ ])('flags %s as unsafe', (path) => {
316
+ expect(isUnsafeApiRequestPath(path)).toBe(true)
317
+ })
318
+
319
+ it.each([
320
+ '/api/customers/companies',
321
+ '/api/customers/companies/company-1',
322
+ 'customers/companies/company-1?expand=1',
323
+ '/api/catalog/products/1.2.3',
324
+ '/api/customers/companies/00000000-0000-0000-0000-000000000000',
325
+ ])('treats %s as safe', (path) => {
326
+ expect(isUnsafeApiRequestPath(path)).toBe(false)
327
+ })
328
+ })
329
+
330
+ it('denies a traversal path even when a parameterized endpoint would match', async () => {
331
+ mockedGetApiEndpoints.mockReset()
332
+ // A documented read the user is authorized for. The un-normalized segment
333
+ // matcher would have accepted '/api/foo/..' against '/api/foo/{id}', then
334
+ // the wire fetch would collapse it to '/api/admin'. The guard blocks it.
335
+ mockedGetApiEndpoints.mockResolvedValue([
336
+ {
337
+ id: 'get_foo',
338
+ operationId: 'get_foo',
339
+ method: 'GET',
340
+ path: '/api/foo/{id}',
341
+ summary: '',
342
+ description: '',
343
+ tags: [],
344
+ requiredFeatures: [],
345
+ parameters: [],
346
+ requestBodySchema: null,
347
+ deprecated: false,
348
+ },
349
+ ])
350
+
351
+ const result = await authorizeCodeModeApiRequest(createContext(), 'GET', '/api/foo/..')
352
+
353
+ expect(result).toEqual({
354
+ allowed: false,
355
+ statusCode: 403,
356
+ error: 'Code Mode rejected unsafe API path: GET /api/foo/..',
357
+ })
358
+ })
359
+
360
+ it('never issues the wire request for a traversal path', async () => {
361
+ mockedGetApiEndpoints.mockReset()
362
+ mockedFetchWithTimeout.mockReset()
363
+ mockedFetchWithTimeout.mockResolvedValue(okResponse())
364
+ mockedGetApiEndpoints.mockResolvedValue([
365
+ {
366
+ id: 'create_company',
367
+ operationId: 'create_company',
368
+ method: 'POST',
369
+ path: '/api/customers/companies',
370
+ summary: '',
371
+ description: '',
372
+ tags: [],
373
+ requiredFeatures: ['customers.companies.create'],
374
+ parameters: [],
375
+ requestBodySchema: null,
376
+ deprecated: false,
377
+ },
378
+ ])
379
+
380
+ const apiRequest = createApiRequestFn(
381
+ createContext({ userFeatures: ['ai_assistant.view', 'customers.companies.create'] }),
382
+ () => {}
383
+ )
384
+
385
+ const result = (await apiRequest({
386
+ method: 'POST',
387
+ path: '/api/customers/companies/../admin',
388
+ body: {},
389
+ })) as { success: boolean; statusCode: number }
390
+
391
+ expect(result.success).toBe(false)
392
+ expect(result.statusCode).toBe(403)
393
+ expect(mockedFetchWithTimeout).not.toHaveBeenCalled()
394
+ })
395
+ })
396
+
290
397
  describe('mutation call cap (issue #2724)', () => {
291
398
  beforeEach(() => {
292
399
  mockedGetApiEndpoints.mockReset()
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url'
5
5
  import * as registry from '@open-mercato/shared/modules/registry'
6
6
  import {
7
7
  rewriteGeneratedAliasImports,
8
+ escapeUnsafeJsStringChars,
8
9
  findGeneratedFile,
9
10
  ensureApiRouteManifestsRegistered,
10
11
  } from '../generated-registry-loader'
@@ -13,6 +14,14 @@ function makeTempDir(): string {
13
14
  return fs.mkdtempSync(path.join(os.tmpdir(), 'om-gen-loader-'))
14
15
  }
15
16
 
17
+ // Mirror the production sanitizer so expectations match how the rewriter
18
+ // embeds a resolved path into the generated source. For ordinary file URLs the
19
+ // escape is a no-op, but routing the expected literal through the same helper
20
+ // keeps the test honest if the rewriter's escaping ever changes.
21
+ function safeJsLiteral(value: string): string {
22
+ return escapeUnsafeJsStringChars(JSON.stringify(value))
23
+ }
24
+
16
25
  describe('rewriteGeneratedAliasImports', () => {
17
26
  // Regression for the MCP dev-server crash:
18
27
  // "Cannot find package '@/.mercato' imported from .../tool-loader.js"
@@ -28,7 +37,7 @@ describe('rewriteGeneratedAliasImports', () => {
28
37
  const expectedUrl = pathToFileURL(
29
38
  path.join(appRoot, '.mercato/generated/entities'),
30
39
  ).href
31
- expect(out).toBe(`import { x } from ${JSON.stringify(expectedUrl)}`)
40
+ expect(out).toBe(`import { x } from ${safeJsLiteral(expectedUrl)}`)
32
41
  expect(out).not.toContain('@/')
33
42
  })
34
43
 
@@ -40,7 +49,7 @@ describe('rewriteGeneratedAliasImports', () => {
40
49
  const expectedUrl = pathToFileURL(
41
50
  path.join(appRoot, '.mercato/generated/ai-tools.generated'),
42
51
  ).href
43
- const expected = source.replace(aliasSpecifier, JSON.stringify(expectedUrl))
52
+ const expected = source.replace(aliasSpecifier, safeJsLiteral(expectedUrl))
44
53
  expect(out).toBe(expected)
45
54
  expect(out).not.toContain('@/')
46
55
  })
@@ -53,7 +62,7 @@ describe('rewriteGeneratedAliasImports', () => {
53
62
  const out = rewriteGeneratedAliasImports(`import x from '@/src/thing'`, appRoot)
54
63
 
55
64
  const expectedUrl = pathToFileURL(path.join(appRoot, 'src', 'thing.ts')).href
56
- expect(out).toBe(`import x from ${JSON.stringify(expectedUrl)}`)
65
+ expect(out).toBe(`import x from ${safeJsLiteral(expectedUrl)}`)
57
66
  })
58
67
 
59
68
  it('leaves non-alias imports untouched', () => {
@@ -66,6 +75,24 @@ describe('rewriteGeneratedAliasImports', () => {
66
75
  })
67
76
  })
68
77
 
78
+ describe('escapeUnsafeJsStringChars', () => {
79
+ it('escapes characters JSON.stringify leaves intact in a JS string literal', () => {
80
+ const lineSep = String.fromCharCode(0x2028)
81
+ const paraSep = String.fromCharCode(0x2029)
82
+ const out = escapeUnsafeJsStringChars(`a<b>c${lineSep}d${paraSep}e`)
83
+ expect(out).toBe('a\\u003Cb\\u003Ec\\u2028d\\u2029e')
84
+ expect(out).not.toContain('<')
85
+ expect(out).not.toContain('>')
86
+ expect(out).not.toContain(lineSep)
87
+ expect(out).not.toContain(paraSep)
88
+ })
89
+
90
+ it('leaves ordinary file URLs unchanged', () => {
91
+ const url = pathToFileURL(path.join('/tmp', 'app', '.mercato', 'x.generated')).href
92
+ expect(escapeUnsafeJsStringChars(JSON.stringify(url))).toBe(JSON.stringify(url))
93
+ })
94
+ })
95
+
69
96
  describe('findGeneratedFile', () => {
70
97
  let cwdSpy: jest.SpyInstance
71
98
 
@@ -58,7 +58,7 @@ describe('McpClient stdio transport', () => {
58
58
  command: 'node',
59
59
  args: ['server.js'],
60
60
  cwd: '/tmp/open-mercato',
61
- env: process.env,
61
+ env: { ...process.env, OPEN_MERCATO_API_KEY: 'test-secret' },
62
62
  stderr: 'pipe',
63
63
  })
64
64
  expect(mockClientConnect).toHaveBeenCalledTimes(1)
@@ -71,4 +71,23 @@ describe('McpClient stdio transport', () => {
71
71
  expect(mockTransportClose).toHaveBeenCalledTimes(1)
72
72
  expect(mockManualChildKill).not.toHaveBeenCalled()
73
73
  })
74
+
75
+ it('passes the API key via OPEN_MERCATO_API_KEY env, never on argv (issue #2669)', async () => {
76
+ await McpClient.connect({
77
+ transport: 'stdio',
78
+ apiKeySecret: 'omk_secret.value',
79
+ })
80
+
81
+ expect(StdioClientTransport).toHaveBeenCalledTimes(1)
82
+ const transportArgs = (StdioClientTransport as jest.Mock).mock.calls[0][0]
83
+
84
+ // The secret must reach the child process exclusively through the env var.
85
+ expect(transportArgs.env.OPEN_MERCATO_API_KEY).toBe('omk_secret.value')
86
+
87
+ // It must NOT appear on argv (world-readable via ps / /proc/<pid>/cmdline).
88
+ expect(transportArgs.args).toEqual(['mercato', 'ai_assistant', 'mcp:serve'])
89
+ expect(transportArgs.args).not.toContain('--api-key')
90
+ expect(transportArgs.args).not.toContain('omk_secret.value')
91
+ expect(JSON.stringify(transportArgs.args)).not.toContain('omk_secret.value')
92
+ })
74
93
  })