Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -269,5 +269,6 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic

# Zoom member search: separate General OAuth app to preserve workflow grants.
# Register ${NEXT_PUBLIC_APP_URL}/api/mcp/oauth/callback with the two meeting read scopes.
# ZOOM_SEARCH=false # Off-AppConfig fallback; set true to enable for eligible organization-owned scopes
# ZOOM_MCP_CLIENT_ID=
# ZOOM_MCP_CLIENT_SECRET=
34 changes: 33 additions & 1 deletion apps/sim/lib/core/utils/bounded-json.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json'
import { isJsonWithinByteLimit, stringifyBoundedJson } from '@/lib/core/utils/bounded-json'

describe('bounded JSON', () => {
it.each([
Expand All @@ -13,11 +13,14 @@ describe('bounded JSON', () => {
const bytes = Buffer.byteLength(json, 'utf8')
expect(stringifyBoundedJson(value, bytes)).toBe(json)
expect(stringifyBoundedJson(value, bytes - 1)).toBeUndefined()
expect(isJsonWithinByteLimit(value, bytes)).toBe(true)
expect(isJsonWithinByteLimit(value, bytes - 1)).toBe(false)
})

it('rejects cycles, excessive depth, and excessive nodes', () => {
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
expect(isJsonWithinByteLimit(cyclic, 1024)).toBe(false)
let deep: unknown = 'leaf'
for (let index = 0; index < 66; index++) deep = { child: deep }
for (const value of [
Expand All @@ -37,6 +40,7 @@ describe('bounded JSON', () => {
const custom = Object.defineProperty({}, 'toJSON', { value: toJSON })
for (const value of [accessor, custom, { output: new Uint8Array([1, 2, 3]) }]) {
expect(stringifyBoundedJson(value, 1024)).toBeUndefined()
expect(isJsonWithinByteLimit(value, 1024)).toBe(false)
}
expect(getter).not.toHaveBeenCalled()
expect(toJSON).not.toHaveBeenCalled()
Expand All @@ -47,6 +51,7 @@ describe('bounded JSON', () => {
const serialize = vi.spyOn(JSON, 'stringify')
try {
expect(stringifyBoundedJson(value, 1024)).toBeUndefined()
expect(isJsonWithinByteLimit(value, 1024)).toBe(false)
expect(serialize).not.toHaveBeenCalled()
} finally {
serialize.mockRestore()
Expand All @@ -61,6 +66,7 @@ describe('bounded JSON', () => {
const serialize = vi.spyOn(JSON, 'stringify')
try {
expect(stringifyBoundedJson(value, 1024)).toBeUndefined()
expect(isJsonWithinByteLimit(value, 1024)).toBe(false)
expect(serialize).not.toHaveBeenCalled()
} finally {
serialize.mockRestore()
Expand All @@ -71,6 +77,7 @@ describe('bounded JSON', () => {
const get = vi.fn(() => 'UNADMITTED')
const value = new Proxy({ text: 'admitted' }, { get })
expect(stringifyBoundedJson(value, 1024)).toBe('{"text":"admitted"}')
expect(isJsonWithinByteLimit(value, 19)).toBe(true)
expect(get).not.toHaveBeenCalled()
})

Expand All @@ -79,12 +86,37 @@ describe('bounded JSON', () => {
const prototype = Object.create(Array.prototype, { 0: { get } })
const value = Object.setPrototypeOf(Array(1), prototype)
expect(stringifyBoundedJson(value, 1024)).toBe('[null]')
expect(isJsonWithinByteLimit(value, 6)).toBe(true)
expect(get).not.toHaveBeenCalled()
})

it('allows repeated references without treating them as a cycle', () => {
const result = { answer: 42 }
const value = { rawResponse: result, modelResponse: result }
expect(stringifyBoundedJson(value, 1024)).toBe(JSON.stringify(value))
expect(isJsonWithinByteLimit(value, Buffer.byteLength(JSON.stringify(value)))).toBe(true)
})

it('counts an ordinary toJSON data field without invoking custom serialization', () => {
const value = { toJSON: 'ordinary JSON field', nested: [{}, [], { flag: true }] }
const bytes = Buffer.byteLength(JSON.stringify(value))
expect(isJsonWithinByteLimit(value, bytes)).toBe(true)
expect(isJsonWithinByteLimit(value, bytes - 1)).toBe(false)
})

it.each(['method', 'accessor'] as const)(
'rejects an inherited toJSON %s without invoking it or ignoring an own shadow',
(kind) => {
const serialize = vi.fn(() => 'x'.repeat(2048))
const prototype = Object.create(Array.prototype, {
toJSON: kind === 'method' ? { value: serialize } : { get: serialize },
})
const value = Object.setPrototypeOf([], Object.create(prototype))
expect(isJsonWithinByteLimit(value, 1024)).toBe(false)
expect(serialize).not.toHaveBeenCalled()
Object.defineProperty(value, 'toJSON', { value: null })
expect(isJsonWithinByteLimit(value, 2)).toBe(true)
expect(serialize).not.toHaveBeenCalled()
}
)
})
92 changes: 92 additions & 0 deletions apps/sim/lib/core/utils/bounded-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,98 @@ function quotedStringBytes(value: string, remaining: number): number | undefined
return bytes <= remaining ? bytes : undefined
}

/** Measures plain JSON iteratively without serialization, copying, or additional node/depth caps. */
export function isJsonWithinByteLimit(value: unknown, maxBytes: number): boolean {
if (!Number.isFinite(maxBytes) || maxBytes < 0 || value === undefined) return false
const invalid = Symbol('invalid JSON')
const ancestors = new WeakSet<object>()
const stack: {
value: object
entries: Generator<[string | null, unknown]>
count: number
}[] = []
let bytes = 0
const omitted = (item: unknown) =>
item === undefined || typeof item === 'function' || typeof item === 'symbol'
function* entries(container: object): Generator<[string | null, unknown]> {
if (Array.isArray(container)) {
const length = Object.getOwnPropertyDescriptor(container, 'length')?.value
if (typeof length !== 'number') {
yield [null, invalid]
return
}
for (let index = 0; index < length; index++) {
const field = Object.getOwnPropertyDescriptor(container, index)
if (field && !('value' in field)) {
yield [null, invalid]
return
}
yield [null, omitted(field?.value) ? null : field?.value]
}
} else {
for (const key in container) {
const field = Object.getOwnPropertyDescriptor(container, key)
if (!field?.enumerable) continue
if (!('value' in field)) {
yield [key, invalid]
return
}
if (!omitted(field.value)) yield [key, field.value]
}
}
}
try {
let item: unknown = value
for (;;) {
if (typeof item === 'string') {
const count = quotedStringBytes(item, maxBytes - bytes)
if (count === undefined) return false
bytes += count
} else if (item === null) bytes += 4
else if (typeof item === 'number') bytes += Number.isFinite(item) ? String(item).length : 4
else if (typeof item === 'boolean') bytes += item ? 4 : 5
else if (typeof item === 'object') {
if (ancestors.has(item)) return false
const prototype = Object.getPrototypeOf(item)
if (!Array.isArray(item) && prototype !== Object.prototype && prototype !== null)
return false
for (let owner: object | null = item; owner; owner = Object.getPrototypeOf(owner)) {
const serializer = Object.getOwnPropertyDescriptor(owner, 'toJSON')
if (!serializer) continue
if (!('value' in serializer) || typeof serializer.value === 'function') return false
break
}
bytes += 2
ancestors.add(item)
stack.push({ value: item, entries: entries(item), count: 0 })
} else return false
if (bytes > maxBytes) return false
for (;;) {
const frame = stack[stack.length - 1]
if (!frame) return true
const next = frame.entries.next()
if (next.done) {
ancestors.delete(frame.value)
stack.pop()
continue
}
if (frame.count++ > 0) bytes++
const [key, child] = next.value
if (key !== null) {
const count = quotedStringBytes(key, maxBytes - bytes)
if (count === undefined) return false
bytes += count + 1
}
if (bytes > maxBytes) return false
item = child
break
}
}
} catch {
return false
}
}

/** Captures bounded plain JSON once, without executing accessors or serializing the source graph. */
export function stringifyBoundedJson(value: unknown, maxBytes: number): string | undefined {
let nodes = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@sim/db/schema'
import * as dns from '@sim/security/dns'
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
import { createDeferred } from '@sim/testing/helpers/deferred'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { toRecord } from '@sim/utils/object'
Expand All @@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import { listSearchIntegrationsContract } from '@/lib/api/contracts/knowledge/search-integrations'
import { env } from '@/lib/core/config/env'
import { createOrganizationAccountsGroup } from '@/lib/credential-groups/workspace-accounts'
import { tryAcquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
import { acquireAdvisoryXactLock, tryAcquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
import {
approveSearchIntegration,
listSearchIntegrations,
Expand Down Expand Up @@ -195,6 +196,63 @@ describe('atomic organization live Search MCP setup', () => {
}
)

it('rejects Zoom approval when rollout is disabled while waiting for the accounts lock', async () => {
const group = await db.transaction((tx) =>
createOrganizationAccountsGroup(tx, ids.organization, ids.owner)
)
await db.insert(mcpServers).values({
id: generateId(),
organizationId: ids.organization,
credentialGroupId: group.id,
managedConnectorId: 'zoom',
name: 'Zoom',
transport: 'streamable-http',
url: 'https://mcp.zoom.us/mcp/meeting/streamable',
authType: 'oauth',
enabled: true,
createdBy: ids.owner,
})
Object.assign(env, { ZOOM_SEARCH: true })
const before = await snapshot()
const locked = createDeferred<number>()
const release = createDeferred<void>()
const blocker = db.transaction(async (tx) => {
await acquireAdvisoryXactLock(
tx,
'search_accounts',
`search-accounts:organization:${ids.organization}`
)
const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
locked.resolve(connection.pid)
await release.promise
})
const blockerPid = await locked.promise
const attempt = approve('zoom').catch((error: unknown) => error)
try {
await vi.waitFor(
async () => {
const [state] = await db.execute<{ waiting: boolean }>(sql`
SELECT EXISTS (
SELECT 1 FROM pg_stat_activity
WHERE ${blockerPid} = ANY(pg_blocking_pids(pid))
) AS waiting
`)
expect(state.waiting).toBe(true)
},
{ timeout: 5_000 }
)
Object.assign(env, { ZOOM_SEARCH: false })
} finally {
release.resolve()
await blocker
await attempt
}
expect(await attempt).toMatchObject({ code: 'forbidden' })
expect(await snapshot()).toEqual(before)
Object.assign(env, { ZOOM_SEARCH: true })
await expect(approve('zoom')).resolves.toMatchObject({ approved: true })
})

it('resolves the sign-in server before the approval takes the accounts lock', async () => {
const lockHeldDuringLookup: boolean[] = []
vi.mocked(dns.resolveHostAddresses).mockImplementationOnce(async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/sim-search/live/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Zoom Search defaults off for organization-scoped rollout. Enable selected organi
}
```

Only the canonical organization ID participates in this rollout check. For local or self-hosted deployments, `ZOOM_SEARCH=true` enables Zoom Search globally; leave that boolean fallback off for an organization-targeted rollout. Setup, enrollment and retrieval enforce the flag. The dedicated Zoom MCP Search connector is gated wherever it is invoked, including generic MCP tools; the standard workflow Zoom OAuth/tools remain available. Disabling the flag preserves saved grants and conversations while denying subsequent Search use; existing approvals can still be removed and connected accounts disconnected. Other providers retain the shared Search and credential-group availability policies without a separate provider rollout gate.
Only the canonical organization ID participates in this rollout check. For local or self-hosted deployments, `ZOOM_SEARCH=true` enables Zoom Search for all otherwise eligible organization-owned scopes; personal workspaces without an organization cannot use managed connected accounts. Leave that boolean fallback off for an organization-targeted rollout. Setup, enrollment and retrieval enforce the flag. The dedicated Zoom MCP Search connector is gated wherever it is invoked, including generic MCP tools; the standard workflow Zoom OAuth/tools remain available. Disabling the flag preserves saved grants and conversations while denying subsequent Search use; existing approvals can still be removed and connected accounts disconnected. Other providers retain the shared Search and credential-group availability policies without a separate provider rollout gate.

### Shared invariants

Expand Down
3 changes: 2 additions & 1 deletion apps/sim/lib/sim-search/live/managed-mcp-payload.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { isRecordLike } from '@sim/utils/object'
import { isJsonWithinByteLimit } from '@/lib/core/utils/bounded-json'
import type { McpToolResult } from '@/lib/mcp/types'
import { NativeSearchError } from '@/lib/sim-search/live/http'

const MAX_SEARCH_MCP_PAYLOAD_BYTES = 4 * 1024 * 1024

/** MCP text is untrusted provider data; malformed structured search output is never an empty success. */
export function managedMcpPayload(result: McpToolResult, label: string): unknown {
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > MAX_SEARCH_MCP_PAYLOAD_BYTES)
if (!isJsonWithinByteLimit(result, MAX_SEARCH_MCP_PAYLOAD_BYTES))
throw new NativeSearchError(
'unavailable',
`${label} response exceeded the search size limit. Narrow the query.`
Expand Down
26 changes: 26 additions & 0 deletions apps/sim/lib/sim-search/live/managed-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,30 @@ describe('managed search MCP read boundary', () => {
expect((failure as NativeSearchError).message).toContain('existing Granola account')
expect((failure as NativeSearchError).message).not.toContain('private-provider-detail')
})

it('rejects escaped MCP payload overflow before allocating its JSON representation', () => {
const result = { structuredContent: { ['\u0000'.repeat(800_000)]: 'value' } }
const serialize = vi.spyOn(JSON, 'stringify').mockImplementation(() => {
throw new Error('Oversized payload reached serialization')
})
try {
expect(() => managedMcpPayload(result, 'Fireflies')).toThrow('size limit')
expect(serialize).not.toHaveBeenCalled()
} finally {
serialize.mockRestore()
}
})

it.each(['wide', 'deep'] as const)(
'preserves byte-small %s MCP responses without imposing capture limits',
(shape) => {
let content: unknown = shape === 'wide' ? Array.from({ length: 100_000 }, () => 0) : 'leaf'
if (shape === 'deep') {
for (let index = 0; index < 128; index++) content = { child: content }
}
const result = { structuredContent: content }
expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThan(4 * 1024 * 1024)
expect(managedMcpPayload(result, 'Fireflies')).toEqual(content)
}
)
})
5 changes: 5 additions & 0 deletions apps/sim/lib/sim-search/live/member-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ export async function addOrganizationSearchMcpProvider(
)
.limit(1)
if (existing) {
if (!(await isSearchProviderEnabled(provider, { kind: 'organization', organizationId })))
throw new OrchestrationError(
'forbidden',
'Zoom Search is not available for this organization'
)
if (!existing.enabled)
throw new OrchestrationError(
'validation',
Expand Down
Loading