@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +14 -2
- package/README.md +1 -1
- package/dist/modules/ai_assistant/ai-tools/meta-pack.js +3 -1
- package/dist/modules/ai_assistant/ai-tools/meta-pack.js.map +2 -2
- package/dist/modules/ai_assistant/api/mcp-key/route.js +95 -0
- package/dist/modules/ai_assistant/api/mcp-key/route.js.map +7 -0
- package/dist/modules/ai_assistant/cli.js +14 -6
- package/dist/modules/ai_assistant/cli.js.map +2 -2
- package/dist/modules/ai_assistant/components/McpConfigDialog.js +1 -1
- package/dist/modules/ai_assistant/components/McpConfigDialog.js.map +2 -2
- package/dist/modules/ai_assistant/data/entities.js +4 -0
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js +3 -1
- package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-overrides.js.map +1 -1
- package/dist/modules/ai_assistant/lib/ai-sdk.js.map +1 -1
- package/dist/modules/ai_assistant/lib/attachment-parts.js +10 -3
- package/dist/modules/ai_assistant/lib/attachment-parts.js.map +2 -2
- package/dist/modules/ai_assistant/lib/chat-config.js.map +1 -1
- package/dist/modules/ai_assistant/lib/client-factory.js +0 -8
- package/dist/modules/ai_assistant/lib/client-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/codemode-tools.js +26 -0
- package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js +23 -3
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +1 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +1 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +1 -1
- package/dist/modules/ai_assistant/lib/llm-bootstrap.js.map +1 -1
- package/dist/modules/ai_assistant/lib/mcp-client.js +2 -4
- package/dist/modules/ai_assistant/lib/mcp-client.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js +71 -0
- package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js +1 -30
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-server.js +29 -23
- package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +1 -1
- package/dist/modules/ai_assistant/lib/task-plan-stream.js.map +1 -1
- package/dist/modules/ai_assistant/setup.js +9 -2
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/package.json +11 -12
- package/src/modules/ai_assistant/__tests__/cli-mcp-serve-key.test.ts +63 -0
- package/src/modules/ai_assistant/__tests__/setup-schedules.test.ts +33 -0
- package/src/modules/ai_assistant/ai-tools/__tests__/meta-pack.test.ts +29 -0
- package/src/modules/ai_assistant/ai-tools/meta-pack.ts +7 -1
- package/src/modules/ai_assistant/api/mcp-key/__tests__/route.test.ts +144 -0
- package/src/modules/ai_assistant/api/mcp-key/route.ts +125 -0
- package/src/modules/ai_assistant/cli.ts +18 -5
- package/src/modules/ai_assistant/components/McpConfigDialog.tsx +5 -1
- package/src/modules/ai_assistant/data/__tests__/schema-unique-indexes.test.ts +39 -0
- package/src/modules/ai_assistant/data/entities.ts +5 -0
- package/src/modules/ai_assistant/lib/__tests__/ai-overrides.test.ts +1 -1
- package/src/modules/ai_assistant/lib/__tests__/attachment-parts.test.ts +112 -0
- package/src/modules/ai_assistant/lib/__tests__/client-factory.test.ts +42 -0
- package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +107 -0
- package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +30 -3
- package/src/modules/ai_assistant/lib/__tests__/mcp-client.test.ts +20 -1
- package/src/modules/ai_assistant/lib/__tests__/mcp-dev-key-resolution.test.ts +92 -0
- package/src/modules/ai_assistant/lib/__tests__/mcp-server-auth-fail-closed.test.ts +133 -0
- package/src/modules/ai_assistant/lib/__tests__/task-plan-stream.test.ts +1 -1
- package/src/modules/ai_assistant/lib/ai-api-operation-runner.ts +9 -0
- package/src/modules/ai_assistant/lib/ai-overrides.ts +1 -1
- package/src/modules/ai_assistant/lib/ai-sdk.ts +1 -1
- package/src/modules/ai_assistant/lib/attachment-parts.ts +26 -5
- package/src/modules/ai_assistant/lib/chat-config.ts +1 -1
- package/src/modules/ai_assistant/lib/client-factory.ts +3 -9
- package/src/modules/ai_assistant/lib/codemode-tools.ts +49 -0
- package/src/modules/ai_assistant/lib/generated-registry-loader.ts +35 -3
- package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +1 -1
- package/src/modules/ai_assistant/lib/llm-adapters/google.ts +1 -1
- package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +1 -1
- package/src/modules/ai_assistant/lib/llm-bootstrap.ts +1 -1
- package/src/modules/ai_assistant/lib/mcp-client.ts +9 -5
- package/src/modules/ai_assistant/lib/mcp-dev-key-resolution.ts +107 -0
- package/src/modules/ai_assistant/lib/mcp-dev-server.ts +1 -36
- package/src/modules/ai_assistant/lib/mcp-server.ts +47 -33
- package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +1 -1
- package/src/modules/ai_assistant/lib/task-plan-stream.ts +1 -1
- package/src/modules/ai_assistant/lib/types.ts +7 -0
- 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 ${
|
|
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,
|
|
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 ${
|
|
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
|
-
|
|
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:
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
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('}')) ||
|