@open-mercato/ai-assistant 0.6.5-develop.4882.1.901c3aa813 → 0.6.5-develop.5033.1.c970204a3f

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.
@@ -1,8 +1,13 @@
1
1
  import type { McpToolContext } from '../types'
2
2
  import { getApiEndpoints } from '../api-endpoint-index'
3
+ import { fetchWithTimeout } from '@open-mercato/shared/lib/http/fetchWithTimeout'
3
4
  import {
4
5
  authorizeCodeModeApiRequest,
6
+ CODE_MODE_MAX_API_CALLS,
7
+ CODE_MODE_MAX_MUTATION_CALLS,
5
8
  CODE_MODE_REQUIRED_FEATURES,
9
+ createApiRequestFn,
10
+ isUnsafeHttpMethod,
6
11
  matchApiEndpointPath,
7
12
  } from '../codemode-tools'
8
13
 
@@ -11,7 +16,46 @@ jest.mock('../api-endpoint-index', () => ({
11
16
  getRawOpenApiSpec: jest.fn(),
12
17
  }))
13
18
 
19
+ jest.mock('@open-mercato/shared/lib/http/fetchWithTimeout', () => ({
20
+ fetchWithTimeout: jest.fn(),
21
+ resolveTimeoutMs: jest.fn(() => 30000),
22
+ }))
23
+
14
24
  const mockedGetApiEndpoints = jest.mocked(getApiEndpoints)
25
+ const mockedFetchWithTimeout = jest.mocked(fetchWithTimeout)
26
+
27
+ function okResponse() {
28
+ return {
29
+ ok: true,
30
+ status: 200,
31
+ text: jest.fn().mockResolvedValue('{}'),
32
+ } as unknown as Response
33
+ }
34
+
35
+ /**
36
+ * Replicate the execute() handler's per-run counters so the regression test
37
+ * exercises the same accounting that gates real api.request() calls.
38
+ */
39
+ function createCountingOnCall() {
40
+ let apiCallCount = 0
41
+ let mutationCallCount = 0
42
+ const onCall = (normalizedMethod: string) => {
43
+ apiCallCount++
44
+ if (apiCallCount > CODE_MODE_MAX_API_CALLS) {
45
+ throw new Error(`API call limit exceeded (max ${CODE_MODE_MAX_API_CALLS})`)
46
+ }
47
+ if (isUnsafeHttpMethod(normalizedMethod)) {
48
+ mutationCallCount++
49
+ if (mutationCallCount > CODE_MODE_MAX_MUTATION_CALLS) {
50
+ throw new Error(`Mutation API call limit exceeded (max ${CODE_MODE_MAX_MUTATION_CALLS})`)
51
+ }
52
+ }
53
+ }
54
+ return {
55
+ onCall,
56
+ counts: () => ({ apiCallCount, mutationCallCount }),
57
+ }
58
+ }
15
59
 
16
60
  type MockRbacService = {
17
61
  hasAllFeatures: jest.Mock<boolean, [string[], string[]]>
@@ -242,3 +286,87 @@ describe('authorizeCodeModeApiRequest', () => {
242
286
  })
243
287
  })
244
288
  })
289
+
290
+ describe('mutation call cap (issue #2724)', () => {
291
+ beforeEach(() => {
292
+ mockedGetApiEndpoints.mockReset()
293
+ mockedFetchWithTimeout.mockReset()
294
+ mockedFetchWithTimeout.mockResolvedValue(okResponse())
295
+ // Documented, feature-authorized POST endpoint so RBAC always allows the call.
296
+ mockedGetApiEndpoints.mockResolvedValue([
297
+ {
298
+ id: 'create_company',
299
+ operationId: 'create_company',
300
+ method: 'POST',
301
+ path: '/api/customers/companies',
302
+ summary: '',
303
+ description: '',
304
+ tags: [],
305
+ requiredFeatures: ['customers.companies.create'],
306
+ parameters: [],
307
+ requestBodySchema: null,
308
+ deprecated: false,
309
+ },
310
+ ])
311
+ })
312
+
313
+ function authorizedContext(): McpToolContext {
314
+ return createContext({
315
+ userFeatures: ['ai_assistant.view', 'customers.companies.create'],
316
+ })
317
+ }
318
+
319
+ it('counts a dynamically-built POST method against the mutation cap', async () => {
320
+ const { onCall, counts } = createCountingOnCall()
321
+ const apiRequest = createApiRequestFn(authorizedContext(), onCall)
322
+
323
+ // The method string is built at runtime — the old static regex never saw it.
324
+ const dynamicMethod = 'PO' + 'ST'
325
+ await apiRequest({ method: dynamicMethod, path: '/api/customers/companies', body: {} })
326
+
327
+ expect(counts()).toEqual({ apiCallCount: 1, mutationCallCount: 1 })
328
+ })
329
+
330
+ it('refuses once the dynamically-built mutation cap is exceeded', async () => {
331
+ const { onCall } = createCountingOnCall()
332
+ const apiRequest = createApiRequestFn(authorizedContext(), onCall)
333
+
334
+ const dynamicMethod = ['P', 'O', 'S', 'T'].join('')
335
+ for (let index = 0; index < CODE_MODE_MAX_MUTATION_CALLS; index++) {
336
+ await apiRequest({ method: dynamicMethod, path: '/api/customers/companies', body: {} })
337
+ }
338
+
339
+ await expect(
340
+ apiRequest({ method: dynamicMethod, path: '/api/customers/companies', body: {} })
341
+ ).rejects.toThrow(`Mutation API call limit exceeded (max ${CODE_MODE_MAX_MUTATION_CALLS})`)
342
+
343
+ // Authorized mutations actually hit fetch up to the cap; the cap throws before fetch on the overflow call.
344
+ expect(mockedFetchWithTimeout).toHaveBeenCalledTimes(CODE_MODE_MAX_MUTATION_CALLS)
345
+ })
346
+
347
+ it('does not charge GET reads against the mutation cap', async () => {
348
+ mockedGetApiEndpoints.mockResolvedValue([
349
+ {
350
+ id: 'list_companies',
351
+ operationId: 'list_companies',
352
+ method: 'GET',
353
+ path: '/api/customers/companies',
354
+ summary: '',
355
+ description: '',
356
+ tags: [],
357
+ requiredFeatures: [],
358
+ parameters: [],
359
+ requestBodySchema: null,
360
+ deprecated: false,
361
+ },
362
+ ])
363
+ const { onCall, counts } = createCountingOnCall()
364
+ const apiRequest = createApiRequestFn(createContext(), onCall)
365
+
366
+ for (let index = 0; index < CODE_MODE_MAX_MUTATION_CALLS + 5; index++) {
367
+ await apiRequest({ method: 'get', path: '/api/customers/companies' })
368
+ }
369
+
370
+ expect(counts()).toEqual({ apiCallCount: CODE_MODE_MAX_MUTATION_CALLS + 5, mutationCallCount: 0 })
371
+ })
372
+ })
@@ -0,0 +1,70 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import { pickDefaultTenant } from '../tool-test-runner'
3
+
4
+ type ExecuteCall = { sql: string; params?: unknown[] }
5
+
6
+ function makeContainer(
7
+ rowsBySql: (sql: string) => Record<string, unknown>[],
8
+ calls: ExecuteCall[],
9
+ ): AwilixContainer {
10
+ const connection = {
11
+ execute: async (sql: string, params?: unknown[]) => {
12
+ calls.push({ sql, params })
13
+ return rowsBySql(sql)
14
+ },
15
+ }
16
+ const em = { getConnection: () => connection }
17
+ return {
18
+ resolve: (token: string) => {
19
+ if (token === 'em') return em
20
+ throw new Error(`[internal] unexpected resolve token: ${token}`)
21
+ },
22
+ } as unknown as AwilixContainer
23
+ }
24
+
25
+ describe('pickDefaultTenant SQL safety (#2725)', () => {
26
+ it('binds the tenant id as a query parameter instead of interpolating it', async () => {
27
+ const calls: ExecuteCall[] = []
28
+ const tenantId = '11111111-2222-3333-4444-555555555555'
29
+ const orgId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
30
+ const userId = 'ffffffff-0000-1111-2222-333333333333'
31
+ const container = makeContainer((sql) => {
32
+ if (sql.includes('FROM tenants')) return [{ id: tenantId }]
33
+ if (sql.includes('FROM organizations')) return [{ id: orgId }]
34
+ if (sql.includes('FROM users')) return [{ id: userId }]
35
+ return []
36
+ }, calls)
37
+
38
+ const result = await pickDefaultTenant(container)
39
+
40
+ expect(result).toEqual({ tenantId, organizationId: orgId, userId })
41
+
42
+ const orgCall = calls.find((call) => call.sql.includes('FROM organizations'))
43
+ const userCall = calls.find((call) => call.sql.includes('FROM users'))
44
+ expect(orgCall).toBeDefined()
45
+ expect(userCall).toBeDefined()
46
+
47
+ // Tenant value flows through bound parameters, never string-interpolated.
48
+ expect(orgCall?.params).toEqual([tenantId])
49
+ expect(userCall?.params).toEqual([tenantId])
50
+ expect(orgCall?.sql).toContain('tenant_id = ?')
51
+ expect(userCall?.sql).toContain('tenant_id = ?')
52
+
53
+ // No call may embed the tenant value or hand-rolled quote-escaping in SQL.
54
+ for (const call of calls) {
55
+ expect(call.sql).not.toContain(tenantId)
56
+ expect(call.sql).not.toContain("''")
57
+ }
58
+ })
59
+
60
+ it('returns null when no tenant exists without issuing scoped lookups', async () => {
61
+ const calls: ExecuteCall[] = []
62
+ const container = makeContainer(() => [], calls)
63
+
64
+ const result = await pickDefaultTenant(container)
65
+
66
+ expect(result).toBeNull()
67
+ expect(calls).toHaveLength(1)
68
+ expect(calls[0]?.sql).toContain('FROM tenants')
69
+ })
70
+ })
@@ -550,22 +550,10 @@ function buildEntitySchemas(graph: EntityGraph) {
550
550
  })
551
551
  }
552
552
 
553
- /**
554
- * Detect mutation HTTP methods in code via static analysis.
555
- * Returns which methods were found (POST, PUT, PATCH, DELETE).
556
- */
557
- function detectMutationInCode(code: string): { hasMutation: boolean; methods: string[] } {
558
- const methods: string[] = []
559
- const pattern = /method:\s*['"](\w+)['"]/gi
560
- let match
561
- while ((match = pattern.exec(code)) !== null) {
562
- const method = match[1].toUpperCase()
563
- if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
564
- methods.push(method)
565
- }
566
- }
567
- return { hasMutation: methods.length > 0, methods }
568
- }
553
+ /** Maximum api.request() calls allowed per execute() run, regardless of method. */
554
+ export const CODE_MODE_MAX_API_CALLS = 50
555
+ /** Maximum mutation (non-GET/HEAD/OPTIONS) api.request() calls allowed per execute() run. */
556
+ export const CODE_MODE_MAX_MUTATION_CALLS = 20
569
557
 
570
558
  /**
571
559
  * Load and register the two Code Mode tools.
@@ -701,19 +689,24 @@ RULES: For FIND/LIST → GET only (1 call). For UPDATE → PUT to collection pat
701
689
  }
702
690
  }
703
691
 
704
- // Detect mutations via static analysis — cap API calls for safety
705
- const mutationInfo = detectMutationInCode(input.code)
706
- const maxApiCalls = mutationInfo.hasMutation ? 20 : 50
707
- if (mutationInfo.hasMutation) {
708
- console.error(`[AI Usage] execute: MUTATION DETECTED (${mutationInfo.methods.join(',')}) — capping API calls to ${maxApiCalls}`)
709
- }
692
+ // Cap API calls for safety. The mutation cap is enforced against the
693
+ // actually-observed HTTP method, not a static scan of the source — so a
694
+ // dynamically-built method (e.g. 'PO' + 'ST') can never escape it.
695
+ const maxApiCalls = CODE_MODE_MAX_API_CALLS
710
696
  let apiCallCount = 0
697
+ let mutationCallCount = 0
711
698
 
712
- const apiRequestFn = createApiRequestFn(ctx, () => {
699
+ const apiRequestFn = createApiRequestFn(ctx, (normalizedMethod) => {
713
700
  apiCallCount++
714
701
  if (apiCallCount > maxApiCalls) {
715
702
  throw new Error(`API call limit exceeded (max ${maxApiCalls})`)
716
703
  }
704
+ if (isUnsafeHttpMethod(normalizedMethod)) {
705
+ mutationCallCount++
706
+ if (mutationCallCount > CODE_MODE_MAX_MUTATION_CALLS) {
707
+ throw new Error(`Mutation API call limit exceeded (max ${CODE_MODE_MAX_MUTATION_CALLS})`)
708
+ }
709
+ }
717
710
  })
718
711
 
719
712
  const context = {
@@ -761,9 +754,9 @@ RULES: For FIND/LIST → GET only (1 call). For UPDATE → PUT to collection pat
761
754
  /**
762
755
  * Create the api.request() function for the execute sandbox.
763
756
  */
764
- function createApiRequestFn(
757
+ export function createApiRequestFn(
765
758
  ctx: McpToolContext,
766
- onCall: () => void
759
+ onCall: (normalizedMethod: string) => void
767
760
  ): (params: {
768
761
  method: string
769
762
  path: string
@@ -777,11 +770,10 @@ function createApiRequestFn(
777
770
  'http://localhost:3000'
778
771
 
779
772
  return async (params) => {
780
- onCall()
781
-
782
773
  const { method, path, query, body } = params
783
774
  const callStart = Date.now()
784
- const normalizedMethod = method.toUpperCase()
775
+ const normalizedMethod = String(method ?? '').toUpperCase()
776
+ onCall(normalizedMethod)
785
777
  const apiPath = normalizeApiRequestPath(path)
786
778
  const authorization = await authorizeCodeModeApiRequest(ctx, normalizedMethod, apiPath)
787
779
 
@@ -995,7 +987,7 @@ function isPathParameterSegment(segment: string): boolean {
995
987
  )
996
988
  }
997
989
 
998
- function isUnsafeHttpMethod(method: string): boolean {
990
+ export function isUnsafeHttpMethod(method: string): boolean {
999
991
  return !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())
1000
992
  }
1001
993
 
@@ -212,14 +212,18 @@ async function loadGeneratedTools(): Promise<{ moduleId: string; tools: AiToolDe
212
212
  return result
213
213
  }
214
214
 
215
- async function pickDefaultTenant(
215
+ export async function pickDefaultTenant(
216
216
  container: AwilixContainer,
217
217
  ): Promise<{ tenantId: string; organizationId: string | null; userId: string | null } | null> {
218
218
  try {
219
219
  const em = container.resolve<{
220
- getConnection: () => { execute: (sql: string) => Promise<unknown[]> }
220
+ getConnection: () => {
221
+ execute: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>
222
+ }
221
223
  }>('em') as unknown as {
222
- getConnection: () => { execute: (sql: string) => Promise<Record<string, unknown>[]> }
224
+ getConnection: () => {
225
+ execute: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>
226
+ }
223
227
  }
224
228
  const conn = em.getConnection()
225
229
  const tenantRows = await conn.execute(
@@ -230,16 +234,17 @@ async function pickDefaultTenant(
230
234
  ? String((tenantRows[0] as Record<string, unknown>).id)
231
235
  : null
232
236
  if (!tenantId) return null
233
- const escaped = tenantId.replace(/'/g, "''")
234
237
  const orgRows = await conn.execute(
235
- `SELECT id FROM organizations WHERE tenant_id = '${escaped}' AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,
238
+ `SELECT id FROM organizations WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,
239
+ [tenantId],
236
240
  )
237
241
  const organizationId =
238
242
  Array.isArray(orgRows) && orgRows[0]
239
243
  ? String((orgRows[0] as Record<string, unknown>).id)
240
244
  : null
241
245
  const userRows = await conn.execute(
242
- `SELECT id FROM users WHERE tenant_id = '${escaped}' AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,
246
+ `SELECT id FROM users WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,
247
+ [tenantId],
243
248
  )
244
249
  const userId =
245
250
  Array.isArray(userRows) && userRows[0]