@open-mercato/ai-assistant 0.6.5-develop.5337.1.534b781eac → 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 (82) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +14 -2
  3. package/README.md +1 -1
  4. package/dist/modules/ai_assistant/ai-tools/meta-pack.js +3 -1
  5. package/dist/modules/ai_assistant/ai-tools/meta-pack.js.map +2 -2
  6. package/dist/modules/ai_assistant/api/mcp-key/route.js +95 -0
  7. package/dist/modules/ai_assistant/api/mcp-key/route.js.map +7 -0
  8. package/dist/modules/ai_assistant/cli.js +14 -6
  9. package/dist/modules/ai_assistant/cli.js.map +2 -2
  10. package/dist/modules/ai_assistant/components/McpConfigDialog.js +1 -1
  11. package/dist/modules/ai_assistant/components/McpConfigDialog.js.map +2 -2
  12. package/dist/modules/ai_assistant/data/entities.js +4 -0
  13. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  14. package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js +3 -1
  15. package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js.map +2 -2
  16. package/dist/modules/ai_assistant/lib/ai-overrides.js.map +1 -1
  17. package/dist/modules/ai_assistant/lib/ai-sdk.js.map +1 -1
  18. package/dist/modules/ai_assistant/lib/attachment-parts.js +10 -3
  19. package/dist/modules/ai_assistant/lib/attachment-parts.js.map +2 -2
  20. package/dist/modules/ai_assistant/lib/chat-config.js.map +1 -1
  21. package/dist/modules/ai_assistant/lib/client-factory.js +0 -8
  22. package/dist/modules/ai_assistant/lib/client-factory.js.map +2 -2
  23. package/dist/modules/ai_assistant/lib/codemode-tools.js +26 -0
  24. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
  25. package/dist/modules/ai_assistant/lib/generated-registry-loader.js +23 -3
  26. package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
  27. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +1 -1
  28. package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +1 -1
  29. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +1 -1
  30. package/dist/modules/ai_assistant/lib/llm-bootstrap.js.map +1 -1
  31. package/dist/modules/ai_assistant/lib/mcp-client.js +2 -4
  32. package/dist/modules/ai_assistant/lib/mcp-client.js.map +2 -2
  33. package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js +71 -0
  34. package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js.map +7 -0
  35. package/dist/modules/ai_assistant/lib/mcp-dev-server.js +1 -30
  36. package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
  37. package/dist/modules/ai_assistant/lib/mcp-server.js +29 -23
  38. package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
  39. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +1 -1
  40. package/dist/modules/ai_assistant/lib/task-plan-stream.js.map +1 -1
  41. package/dist/modules/ai_assistant/setup.js +9 -2
  42. package/dist/modules/ai_assistant/setup.js.map +2 -2
  43. package/package.json +11 -12
  44. package/src/modules/ai_assistant/__tests__/cli-mcp-serve-key.test.ts +63 -0
  45. package/src/modules/ai_assistant/__tests__/setup-schedules.test.ts +33 -0
  46. package/src/modules/ai_assistant/ai-tools/__tests__/meta-pack.test.ts +29 -0
  47. package/src/modules/ai_assistant/ai-tools/meta-pack.ts +7 -1
  48. package/src/modules/ai_assistant/api/mcp-key/__tests__/route.test.ts +144 -0
  49. package/src/modules/ai_assistant/api/mcp-key/route.ts +125 -0
  50. package/src/modules/ai_assistant/cli.ts +18 -5
  51. package/src/modules/ai_assistant/components/McpConfigDialog.tsx +5 -1
  52. package/src/modules/ai_assistant/data/__tests__/schema-unique-indexes.test.ts +39 -0
  53. package/src/modules/ai_assistant/data/entities.ts +5 -0
  54. package/src/modules/ai_assistant/lib/__tests__/ai-overrides.test.ts +1 -1
  55. package/src/modules/ai_assistant/lib/__tests__/attachment-parts.test.ts +112 -0
  56. package/src/modules/ai_assistant/lib/__tests__/client-factory.test.ts +42 -0
  57. package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +107 -0
  58. package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +30 -3
  59. package/src/modules/ai_assistant/lib/__tests__/mcp-client.test.ts +20 -1
  60. package/src/modules/ai_assistant/lib/__tests__/mcp-dev-key-resolution.test.ts +92 -0
  61. package/src/modules/ai_assistant/lib/__tests__/mcp-server-auth-fail-closed.test.ts +133 -0
  62. package/src/modules/ai_assistant/lib/__tests__/task-plan-stream.test.ts +1 -1
  63. package/src/modules/ai_assistant/lib/ai-api-operation-runner.ts +9 -0
  64. package/src/modules/ai_assistant/lib/ai-overrides.ts +1 -1
  65. package/src/modules/ai_assistant/lib/ai-sdk.ts +1 -1
  66. package/src/modules/ai_assistant/lib/attachment-parts.ts +26 -5
  67. package/src/modules/ai_assistant/lib/chat-config.ts +1 -1
  68. package/src/modules/ai_assistant/lib/client-factory.ts +3 -9
  69. package/src/modules/ai_assistant/lib/codemode-tools.ts +49 -0
  70. package/src/modules/ai_assistant/lib/generated-registry-loader.ts +35 -3
  71. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +1 -1
  72. package/src/modules/ai_assistant/lib/llm-adapters/google.ts +1 -1
  73. package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +1 -1
  74. package/src/modules/ai_assistant/lib/llm-bootstrap.ts +1 -1
  75. package/src/modules/ai_assistant/lib/mcp-client.ts +9 -5
  76. package/src/modules/ai_assistant/lib/mcp-dev-key-resolution.ts +107 -0
  77. package/src/modules/ai_assistant/lib/mcp-dev-server.ts +1 -36
  78. package/src/modules/ai_assistant/lib/mcp-server.ts +47 -33
  79. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +1 -1
  80. package/src/modules/ai_assistant/lib/task-plan-stream.ts +1 -1
  81. package/src/modules/ai_assistant/lib/types.ts +7 -0
  82. package/src/modules/ai_assistant/setup.ts +11 -2
@@ -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
  })
@@ -0,0 +1,92 @@
1
+ import { resolve } from 'node:path'
2
+ import { findProjectRoot, checkMcpConfigPermissions } from '../mcp-dev-key-resolution'
3
+
4
+ describe('mcp:dev API key resolution (#2671)', () => {
5
+ describe('findProjectRoot', () => {
6
+ const projectRoot = resolve('/home/dev/project')
7
+ const launchDir = resolve('/home/dev/project/packages/ai-assistant')
8
+
9
+ it('returns the nearest ancestor containing a root marker', () => {
10
+ const exists = (path: string) => path === resolve(projectRoot, '.git')
11
+ expect(findProjectRoot(launchDir, exists)).toBe(projectRoot)
12
+ })
13
+
14
+ it('stops at the nearest marker and never picks a marker higher up the tree', () => {
15
+ // A marker exists both at the real project root and at a higher ancestor.
16
+ // Walking up from the launch dir must stop at the closest one, so an
17
+ // attacker planting a marker (and a sibling .mcp.json) higher up cannot
18
+ // shadow the real project root.
19
+ const higherAncestor = resolve('/home/dev')
20
+ const exists = (path: string) =>
21
+ path === resolve(projectRoot, '.git') || path === resolve(higherAncestor, '.git')
22
+ expect(findProjectRoot(launchDir, exists)).toBe(projectRoot)
23
+ expect(findProjectRoot(launchDir, exists)).not.toBe(higherAncestor)
24
+ })
25
+
26
+ it('falls back to the start directory (never the filesystem root) when no marker is found', () => {
27
+ const exists = () => false
28
+ expect(findProjectRoot(launchDir, exists)).toBe(launchDir)
29
+ expect(findProjectRoot(launchDir, exists)).not.toBe(resolve('/'))
30
+ })
31
+
32
+ it('recognises any of the supported root markers', () => {
33
+ const exists = (path: string) => path === resolve(projectRoot, 'yarn.lock')
34
+ expect(findProjectRoot(launchDir, exists)).toBe(projectRoot)
35
+ })
36
+ })
37
+
38
+ describe('checkMcpConfigPermissions', () => {
39
+ const configPath = '/home/dev/project/.mcp.json'
40
+
41
+ it('refuses to read a config owned by another user', () => {
42
+ const warnings: string[] = []
43
+ const result = checkMcpConfigPermissions(
44
+ configPath,
45
+ { uid: 4242, mode: 0o600 },
46
+ 1000,
47
+ (message) => warnings.push(message),
48
+ )
49
+ expect(result.ok).toBe(false)
50
+ expect(result.reason).toContain('owned by uid 4242')
51
+ expect(warnings).toHaveLength(0)
52
+ })
53
+
54
+ it('warns loudly but allows when the config is group/world accessible', () => {
55
+ const warnings: string[] = []
56
+ const result = checkMcpConfigPermissions(
57
+ configPath,
58
+ { uid: 1000, mode: 0o644 },
59
+ 1000,
60
+ (message) => warnings.push(message),
61
+ )
62
+ expect(result.ok).toBe(true)
63
+ expect(warnings).toHaveLength(1)
64
+ expect(warnings[0]).toContain('accessible to group/other')
65
+ expect(warnings[0]).toContain('chmod 600')
66
+ })
67
+
68
+ it('accepts an owner-only config without warning', () => {
69
+ const warnings: string[] = []
70
+ const result = checkMcpConfigPermissions(
71
+ configPath,
72
+ { uid: 1000, mode: 0o600 },
73
+ 1000,
74
+ (message) => warnings.push(message),
75
+ )
76
+ expect(result.ok).toBe(true)
77
+ expect(warnings).toHaveLength(0)
78
+ })
79
+
80
+ it('skips ownership/permission checks when uid is unavailable (non-POSIX)', () => {
81
+ const warnings: string[] = []
82
+ const result = checkMcpConfigPermissions(
83
+ configPath,
84
+ { uid: 4242, mode: 0o644 },
85
+ null,
86
+ (message) => warnings.push(message),
87
+ )
88
+ expect(result.ok).toBe(true)
89
+ expect(warnings).toHaveLength(0)
90
+ })
91
+ })
92
+ })
@@ -0,0 +1,133 @@
1
+ import { createMcpServer } from '../mcp-server'
2
+ import type { McpServerOptions } from '../types'
3
+
4
+ jest.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
5
+ Server: class {
6
+ setRequestHandler() {}
7
+ },
8
+ }))
9
+ jest.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
10
+ StdioServerTransport: class {},
11
+ }))
12
+ jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
13
+ ListToolsRequestSchema: {},
14
+ CallToolRequestSchema: {},
15
+ }))
16
+ jest.mock('../tool-registry', () => ({
17
+ getToolRegistry: () => ({
18
+ getTools: () => new Map(),
19
+ listToolNames: () => [],
20
+ }),
21
+ }))
22
+ jest.mock('../tool-executor', () => ({ executeTool: jest.fn() }))
23
+ jest.mock('../tool-loader', () => ({
24
+ loadAllModuleTools: jest.fn(),
25
+ indexToolsForSearch: jest.fn(),
26
+ }))
27
+
28
+ const authenticateMcpRequest = jest.fn()
29
+ jest.mock('../auth', () => ({
30
+ authenticateMcpRequest: (...args: unknown[]) => authenticateMcpRequest(...args),
31
+ hasRequiredFeatures: jest.fn(() => true),
32
+ }))
33
+
34
+ const loadAcl = jest.fn()
35
+
36
+ function makeContainer() {
37
+ return {
38
+ resolve: (name: string) => {
39
+ if (name === 'rbacService') return { loadAcl }
40
+ return {}
41
+ },
42
+ } as unknown as McpServerOptions['container']
43
+ }
44
+
45
+ function baseOptions(overrides: Partial<McpServerOptions>): McpServerOptions {
46
+ return {
47
+ config: { name: 'test-mcp', version: '0.0.0', debug: false },
48
+ container: makeContainer(),
49
+ ...overrides,
50
+ } as McpServerOptions
51
+ }
52
+
53
+ describe('issue #2673 — MCP stdio server must fail closed without auth', () => {
54
+ beforeEach(() => {
55
+ authenticateMcpRequest.mockReset()
56
+ loadAcl.mockReset()
57
+ loadAcl.mockResolvedValue({ isSuperAdmin: false, features: ['ai_assistant.view'] })
58
+ jest.spyOn(console, 'error').mockImplementation(() => {})
59
+ })
60
+
61
+ afterEach(() => {
62
+ jest.restoreAllMocks()
63
+ })
64
+
65
+ it('throws when neither apiKeySecret nor context is supplied', async () => {
66
+ await expect(createMcpServer(baseOptions({}))).rejects.toThrow(
67
+ /refused to start: no authentication/i,
68
+ )
69
+ expect(loadAcl).not.toHaveBeenCalled()
70
+ })
71
+
72
+ it('throws when context lacks a userId (the mcp:serve --tenant-without-user path)', async () => {
73
+ await expect(
74
+ createMcpServer(
75
+ baseOptions({ context: { tenantId: 't1', organizationId: 'o1', userId: null } }),
76
+ ),
77
+ ).rejects.toThrow(/refused to start: no authentication/i)
78
+ expect(loadAcl).not.toHaveBeenCalled()
79
+ })
80
+
81
+ it('treats an empty-string apiKeySecret as missing and fails closed', async () => {
82
+ await expect(createMcpServer(baseOptions({ apiKeySecret: '' }))).rejects.toThrow(
83
+ /refused to start: no authentication/i,
84
+ )
85
+ expect(authenticateMcpRequest).not.toHaveBeenCalled()
86
+ })
87
+
88
+ it('treats a whitespace-only apiKeySecret as missing and fails closed', async () => {
89
+ await expect(createMcpServer(baseOptions({ apiKeySecret: ' ' }))).rejects.toThrow(
90
+ /refused to start: no authentication/i,
91
+ )
92
+ expect(authenticateMcpRequest).not.toHaveBeenCalled()
93
+ })
94
+
95
+ it('loads the real user ACL when context carries a non-empty userId', async () => {
96
+ const server = await createMcpServer(
97
+ baseOptions({ context: { tenantId: 't1', organizationId: 'o1', userId: 'u1' } }),
98
+ )
99
+ expect(server).toBeDefined()
100
+ expect(loadAcl).toHaveBeenCalledWith('u1', { tenantId: 't1', organizationId: 'o1' })
101
+ })
102
+
103
+ it('allows unauthenticated superadmin only behind the explicit opt-in, without loading an ACL', async () => {
104
+ const server = await createMcpServer(
105
+ baseOptions({
106
+ context: { tenantId: 't1', organizationId: 'o1', userId: null },
107
+ allowUnauthenticatedSuperadmin: true,
108
+ }),
109
+ )
110
+ expect(server).toBeDefined()
111
+ expect(loadAcl).not.toHaveBeenCalled()
112
+ })
113
+
114
+ it('allows fully unauthenticated superadmin behind the opt-in with no context', async () => {
115
+ const server = await createMcpServer(baseOptions({ allowUnauthenticatedSuperadmin: true }))
116
+ expect(server).toBeDefined()
117
+ })
118
+
119
+ it('still authenticates via a valid apiKeySecret', async () => {
120
+ authenticateMcpRequest.mockResolvedValue({
121
+ success: true,
122
+ tenantId: 't1',
123
+ organizationId: 'o1',
124
+ userId: 'u1',
125
+ features: ['ai_assistant.view'],
126
+ isSuperAdmin: false,
127
+ keyName: 'test-key',
128
+ })
129
+ const server = await createMcpServer(baseOptions({ apiKeySecret: 'omk_valid.secret' }))
130
+ expect(server).toBeDefined()
131
+ expect(authenticateMcpRequest).toHaveBeenCalledWith('omk_valid.secret', expect.anything())
132
+ })
133
+ })
@@ -2,7 +2,7 @@
2
2
  * Tests for the server-side task-plan SSE injector
3
3
  * (`packages/ai-assistant/src/modules/ai_assistant/lib/task-plan-stream.ts`).
4
4
  *
5
- * Covers spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`
5
+ * Covers spec `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`
6
6
  * acceptance criteria:
7
7
  * - runtime-derived labels from tool lifecycle chunks
8
8
  * - agent-authored labels via the safe `meta.update_task_plan` tool
@@ -105,6 +105,14 @@ function buildAuthEnvelope(ctx: AiToolExecutionContext): TrustedAuthContextEnvel
105
105
  if (!userId) {
106
106
  return { auth: null, status: 'invalid' }
107
107
  }
108
+ // MCP api-key contexts use an `api_key:<uuid>` subject (see auth.ts). Surface
109
+ // the bare key UUID as `keyId` so downstream actor logging resolves to a valid
110
+ // UUID — the CRUD factory computes `actorUserId = auth.keyId ?? auth.sub`, and
111
+ // the access-log schema rejects a non-UUID `actorUserId`. This matches real
112
+ // HTTP api-key auth, where `keyId` is the api key record id.
113
+ const apiKeyId = userId.startsWith('api_key:')
114
+ ? userId.slice('api_key:'.length)
115
+ : undefined
108
116
  const auth: AuthContext = {
109
117
  sub: userId,
110
118
  userId,
@@ -112,6 +120,7 @@ function buildAuthEnvelope(ctx: AiToolExecutionContext): TrustedAuthContextEnvel
112
120
  orgId: ctx.organizationId,
113
121
  roles: [],
114
122
  isSuperAdmin: ctx.isSuperAdmin,
123
+ ...(apiKeyId ? { keyId: apiKeyId } : {}),
115
124
  }
116
125
  return { auth, status: 'authenticated' }
117
126
  }
@@ -28,7 +28,7 @@ import {
28
28
  *
29
29
  * `null` always means "remove from the registry"; a definition replaces.
30
30
  *
31
- * @see ../../../../../../.ai/specs/2026-04-30-ai-overrides-and-module-disable.md
31
+ * @see ../../../../../../.ai/specs/implemented/2026-04-30-ai-overrides-and-module-disable.md
32
32
  */
33
33
  import type { AiAgentDefinition, AiAgentExtension } from './ai-agent-definition'
34
34
  import type { AiToolDefinition } from './types'
@@ -11,5 +11,5 @@ export { createGoogleGenerativeAI } from '@ai-sdk/google'
11
11
  // using `generateObject` / `streamText` already has the registry populated.
12
12
  //
13
13
  // @see ./llm-bootstrap.ts
14
- // @see .ai/specs/2026-04-14-llm-provider-ports-and-adapters.md
14
+ // @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md
15
15
  import './llm-bootstrap'
@@ -128,10 +128,23 @@ async function loadAttachmentRow(
128
128
  // core package owns the MikroORM metadata and is the only place tests would
129
129
  // need to bootstrap for real DB access.
130
130
  const { Attachment } = await import('@open-mercato/core/modules/attachments/data/entities')
131
+ // Tenant isolation is enforced in the SQL WHERE clause, not just the JS
132
+ // post-filter below: the decryption scope (5th arg) only drives field
133
+ // decryption, so omitting tenant/org from `where` would let `em.findOne`
134
+ // return a row from another tenant. Super-admins bypass the scope. Org-scoped
135
+ // callers also constrain by org; tenant-wide callers (organizationId === null)
136
+ // may read any org within their tenant.
137
+ const where: Record<string, unknown> = { id: attachmentId }
138
+ if (!authContext.isSuperAdmin) {
139
+ where.tenantId = authContext.tenantId
140
+ if (authContext.organizationId != null) {
141
+ where.organizationId = authContext.organizationId
142
+ }
143
+ }
131
144
  const record = await findOneWithDecryption(
132
145
  em,
133
146
  Attachment as never,
134
- { id: attachmentId } as never,
147
+ where as never,
135
148
  undefined,
136
149
  {
137
150
  tenantId: authContext.tenantId,
@@ -157,10 +170,18 @@ async function loadAttachmentRow(
157
170
 
158
171
  function rowBelongsToCaller(row: AttachmentRow, authContext: AiChatRequestContext): boolean {
159
172
  if (authContext.isSuperAdmin) return true
160
- // Tenant scope: if the record is tenant-scoped, it MUST match the caller tenant.
161
- if (row.tenantId && row.tenantId !== authContext.tenantId) return false
162
- // Organization scope: if the record is org-scoped, it MUST match the caller org.
163
- if (row.organizationId && row.organizationId !== authContext.organizationId) return false
173
+ // Tenant scope: fail closed. The record MUST carry the caller's tenant.
174
+ // A null `tenant_id` (a supported "global"/unscoped attachment state) is NOT
175
+ // accessible through the AI path: it has no `partition.isPublic` gate, so a
176
+ // truthiness short-circuit here would leak the bytes / extracted text into a
177
+ // different tenant's LLM context (cross-tenant IDOR, issue #2663). Requiring
178
+ // strict equality also rejects a non-super-admin caller with a null tenant.
179
+ if (authContext.tenantId == null || row.tenantId !== authContext.tenantId) return false
180
+ // Organization scope: when the caller is org-scoped, the record MUST match
181
+ // that organization (this also rejects null-org rows for an org-scoped
182
+ // caller). Tenant-wide callers (organizationId === null) may read any org
183
+ // within their tenant.
184
+ if (authContext.organizationId != null && row.organizationId !== authContext.organizationId) return false
164
185
  return true
165
186
  }
166
187
 
@@ -12,7 +12,7 @@ import './llm-bootstrap'
12
12
  // refactor the registry accepts any stable id string, so this type
13
13
  // becomes `string`. Backward-compatibility note: downstream callers that
14
14
  // used exhaustive switches on the old union must add a `default:` branch.
15
- // See `.ai/specs/2026-04-14-llm-provider-ports-and-adapters.md`.
15
+ // See `.ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md`.
16
16
  export type ChatProviderId = string
17
17
 
18
18
  export type ChatModelInfo = {
@@ -93,16 +93,10 @@ export async function createMcpClient(options: CreateClientOptions): Promise<Mcp
93
93
 
94
94
  if (options.stdioArgs) {
95
95
  stdioOptions.args = options.stdioArgs
96
- } else {
97
- // Default args include the API key
98
- stdioOptions.args = [
99
- 'mercato',
100
- 'ai_assistant',
101
- 'mcp:serve',
102
- '--api-key',
103
- apiKeySecret,
104
- ]
105
96
  }
97
+ // Otherwise let McpClient.connectStdio apply its default args. The API key
98
+ // is delivered to the child via the OPEN_MERCATO_API_KEY env var, never on
99
+ // argv — command-line arguments are world-readable (ps / /proc/<pid>/cmdline).
106
100
 
107
101
  if (options.cwd) {
108
102
  stdioOptions.cwd = options.cwd
@@ -883,6 +883,15 @@ export async function authorizeCodeModeApiRequest(
883
883
  path: string
884
884
  ): Promise<CodeModeApiAuthorization> {
885
885
  const normalizedMethod = method.toUpperCase()
886
+
887
+ if (isUnsafeApiRequestPath(path)) {
888
+ return {
889
+ allowed: false,
890
+ statusCode: 403,
891
+ error: `Code Mode rejected unsafe API path: ${normalizedMethod} ${path}`,
892
+ }
893
+ }
894
+
886
895
  const normalizedPath = normalizeApiRequestPath(path)
887
896
  const endpoint = await findCodeModeApiEndpoint(normalizedMethod, normalizedPath)
888
897
 
@@ -979,6 +988,46 @@ function normalizeApiRequestPath(path: string): string {
979
988
  return normalizedPath
980
989
  }
981
990
 
991
+ const SINGLE_DOT_SEGMENTS = new Set(['.', '%2e'])
992
+ const DOUBLE_DOT_SEGMENTS = new Set(['..', '.%2e', '%2e.', '%2e%2e'])
993
+
994
+ /**
995
+ * Rejects request paths that the WHATWG URL parser would rewrite before the
996
+ * actual fetch (`..`/`.` path segments — including their percent-encoded forms
997
+ * — backslashes, and percent-encoded separators). Code Mode authorizes the
998
+ * literal path it was given, but `new URL()` collapses dot segments and
999
+ * normalizes backslashes for http(s) URLs, so without this guard the wire
1000
+ * request can resolve to a different endpoint than the one that was authorized.
1001
+ */
1002
+ export function isUnsafeApiRequestPath(path: string): boolean {
1003
+ const [rawPath] = String(path ?? '').split('?')
1004
+
1005
+ // The WHATWG URL parser strips ASCII tab/newline/carriage-return from the URL
1006
+ // before parsing, so a smuggled `.<TAB>.` segment collapses to `..` on the
1007
+ // wire even though the literal segment never equals a dot segment here. Raw
1008
+ // control characters never appear in legitimate REST paths, so reject them.
1009
+ if (/[\u0000-\u001f]/.test(rawPath)) {
1010
+ return true
1011
+ }
1012
+
1013
+ // http(s) URLs treat backslashes as path separators, so they can smuggle
1014
+ // separators past the segment-based authorizer.
1015
+ if (rawPath.includes('\\')) {
1016
+ return true
1017
+ }
1018
+
1019
+ // Percent-encoded separators never appear in legitimate REST paths and let
1020
+ // the literal-'/' segment split desync from the parsed request URL.
1021
+ if (/%2f/i.test(rawPath) || /%5c/i.test(rawPath)) {
1022
+ return true
1023
+ }
1024
+
1025
+ return rawPath.split('/').some((segment) => {
1026
+ const lowered = segment.toLowerCase()
1027
+ return SINGLE_DOT_SEGMENTS.has(lowered) || DOUBLE_DOT_SEGMENTS.has(lowered)
1028
+ })
1029
+ }
1030
+
982
1031
  function isPathParameterSegment(segment: string): boolean {
983
1032
  return (
984
1033
  (segment.startsWith('{') && segment.endsWith('}')) ||