From 7514ffcd920d0a850e192abcd3d4f773917acb34 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 25 Aug 2026 14:20:37 -0700 Subject: [PATCH 1/4] improvement(access-control): wire tool and model permissions into copilot editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permission groups already supported a per-tool denylist (deniedTools) and model restrictions, but only the canvas honored them. The copilot edit path gated on block type alone, so Sim could build workflows using tools and models the user was not allowed to run — the executor refused them at run time instead. Enforce both at authoring time, and stop advertising what the viewer cannot use. --- apps/docs/openapi-v2-workflows.json | 1 + .../access-control/utils/permission-check.ts | 3 +- apps/sim/hooks/use-permission-config.ts | 48 +-- apps/sim/lib/copilot/chat/payload.test.ts | 7 +- apps/sim/lib/copilot/chat/payload.ts | 38 +-- .../integration-tool-projection.test.ts | 175 ++++++++++ .../copilot/integration-tool-projection.ts | 140 ++++++++ .../sim/lib/copilot/integration-tools.test.ts | 24 +- apps/sim/lib/copilot/integration-tools.ts | 13 +- .../tools/handlers/integration-tools.ts | 20 +- .../blocks/get-blocks-metadata-tool.test.ts | 82 +++++ .../server/blocks/get-blocks-metadata-tool.ts | 65 ++++ apps/sim/lib/copilot/vfs/serializers.test.ts | 51 +++ apps/sim/lib/copilot/vfs/serializers.ts | 32 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 184 +++++++---- .../integration-allowlist.ts | 12 + .../permission-groups/model-access.test.ts | 37 +++ .../sim/lib/permission-groups/model-access.ts | 35 ++ .../operation-access.test.ts | 32 ++ .../lib/permission-groups/operation-access.ts | 44 +++ apps/sim/lib/workflows/editing/builders.ts | 117 ++++++- .../lib/workflows/editing/operations.test.ts | 298 ++++++++++++++++++ apps/sim/lib/workflows/editing/operations.ts | 35 +- apps/sim/lib/workflows/editing/types.ts | 1 + packages/sim-cli/src/generated/v2-api.ts | 1 + 25 files changed, 1341 insertions(+), 154 deletions(-) create mode 100644 apps/sim/lib/copilot/integration-tool-projection.test.ts create mode 100644 apps/sim/lib/copilot/integration-tool-projection.ts create mode 100644 apps/sim/lib/permission-groups/model-access.test.ts create mode 100644 apps/sim/lib/permission-groups/model-access.ts diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 949d001b48c..21e0fa1c98c 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -5742,6 +5742,7 @@ "block_not_found", "invalid_block_type", "block_not_allowed", + "model_not_allowed", "block_locked", "tool_not_allowed", "invalid_edge_target", diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 4d6a835c264..fdb7c5cfb9e 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -13,6 +13,7 @@ import { } from '@/lib/core/config/env-flags' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, @@ -745,7 +746,7 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis } } - if (toolId && config?.deniedTools?.includes(toolId)) { + if (toolId && !createToolAccessGate(config?.deniedTools)(toolId)) { logger.warn('Tool blocked by permission group', { userId, workspaceId, toolId }) throw new ToolNotAllowedError(toolId) } diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index b6740a30fb0..da494ece028 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -16,6 +16,8 @@ import { } from '@/lib/integrations/availability' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { createModelAccessGate } from '@/lib/permission-groups/model-access' +import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, @@ -24,7 +26,6 @@ import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/p import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { overlayVisibility } from '@/blocks/visibility/context' import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' -import { findProviderFromModel } from '@/providers/utils' export interface PermissionConfigResult { config: PermissionGroupConfig @@ -120,42 +121,19 @@ export function usePermissionConfig(): PermissionConfigResult { } }, [hostContext?.features?.credentialGroups, integrationAvailability, mergedAllowedIntegrations]) - const isProviderAllowed = useMemo(() => { - return (providerId: string) => { - if (config.allowedModelProviders === null) return true - return config.allowedModelProviders.includes(providerId) - } - }, [config.allowedModelProviders]) - - /** Indexed so the per-model check stays O(1) over a long denylist. */ - const deniedModelSet = useMemo( - () => new Set(config.deniedModels.map((denied) => denied.toLowerCase())), - [config.deniedModels] + const isModelUsable = useMemo( + () => + createModelAccessGate({ + deniedModels: config.deniedModels, + allowedModelProviders: config.allowedModelProviders, + }), + [config.deniedModels, config.allowedModelProviders] ) - const isModelAllowed = useMemo(() => { - return (model: string) => !deniedModelSet.has(model.toLowerCase()) - }, [deniedModelSet]) - - const isModelUsable = useMemo(() => { - return (model: string) => { - if (!isModelAllowed(model)) return false - const providerId = findProviderFromModel(model) - /* Only chat models resolve to a provider. A `model` field holding an - embedding, speech, image or video id is not a provider choice, so the - provider allowlist has nothing to say about it — judging it anyway - would read every such id as Ollama and reject it. */ - if (!providerId) return true - return isProviderAllowed(providerId) - } - }, [isModelAllowed, isProviderAllowed]) - - /** Indexed so the per-tool check stays O(1) over a long denylist. */ - const deniedToolSet = useMemo(() => new Set(config.deniedTools), [config.deniedTools]) - - const isToolAllowed = useMemo(() => { - return (toolId: string) => !deniedToolSet.has(toolId) - }, [deniedToolSet]) + const isToolAllowed = useMemo( + () => createToolAccessGate(config.deniedTools), + [config.deniedTools] + ) const filterBlocks = useMemo(() => { return (blocks: T[]): T[] => { diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 4ec333268bd..af3f1742803 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -76,10 +76,11 @@ vi.mock('@/lib/copilot/block-visibility', () => ({ vi.mock('@/lib/copilot/integration-tools', () => ({ filterExposedIntegrationTools: vi.fn( ( - tools: Array<{ blockType: string; service: string }>, + tools: Array<{ toolId: string; blockType: string; service: string }>, _vis: unknown, - isOwnerAllowed: (owner: { blockType: string; service: string }) => boolean - ) => tools.filter((tool) => isOwnerAllowed(tool)) + isOwnerAllowed: (owner: { blockType: string; service: string }) => boolean, + isToolAllowed: (toolId: string) => boolean = () => true + ) => tools.filter((tool) => isToolAllowed(tool.toolId) && isOwnerAllowed(tool)) ), getExposedIntegrationTools: vi.fn(() => [ { diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 38ca4508f90..4f087776a51 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -8,25 +8,17 @@ import { isPaid } from '@/lib/billing/plan-helpers' import { getBlockVisibilityForCopilot, visibilitySignature } from '@/lib/copilot/block-visibility' import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1' import { - filterExposedIntegrationTools, - getExposedIntegrationTools, -} from '@/lib/copilot/integration-tools' + type IntegrationGateConfig, + projectIntegrationToolsForViewer, +} from '@/lib/copilot/integration-tool-projection' import { buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' import { getToolEntry } from '@/lib/copilot/tool-executor/router' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities' -import { - getAllowedIntegrationsFromEnv, - isDocSandboxEnabled, - isHosted, -} from '@/lib/core/config/env-flags' -import { - isIntegrationDeploymentAvailableForVisibility, - isOAuthServiceDeploymentAvailable, -} from '@/lib/integrations/availability.server' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { buildArchiveExtractGuidance, isArchiveFileName } from '@/lib/uploads/utils/file-utils' import { deriveHostedApiKeySupport } from '@/tools/hosted-api-key' @@ -197,18 +189,11 @@ async function buildIntegrationToolSchemasUncached( ): Promise { const reqLogger = logger.withMetadata({ messageId }) const integrationTools: ToolSchema[] = [] - let allowedIntegrations = getAllowedIntegrationsFromEnv() + let permissionConfig: IntegrationGateConfig | null = null if (workspaceId) { const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check') - const permissionConfig = await getUserPermissionConfig(userId, workspaceId) - allowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - allowedIntegrations - ) + permissionConfig = await getUserPermissionConfig(userId, workspaceId) } - const allowedIntegrationTypes = allowedIntegrations - ? new Set(allowedIntegrations.map((integration) => integration.toLowerCase())) - : null try { const { createUserToolSchema } = await import('@/tools/params') @@ -224,14 +209,7 @@ async function buildIntegrationToolSchemasUncached( }) } - const exposedTools = filterExposedIntegrationTools( - getExposedIntegrationTools(), - vis, - (owner) => - isIntegrationDeploymentAvailableForVisibility(owner.blockType, vis) && - (allowedIntegrationTypes === null || - allowedIntegrationTypes.has(owner.blockType.toLowerCase())) - ) + const { tools: exposedTools } = projectIntegrationToolsForViewer(vis, permissionConfig) for (const { toolId, config: toolConfig, service, operation } of exposedTools) { try { const userSchema = createUserToolSchema(toolConfig, { diff --git a/apps/sim/lib/copilot/integration-tool-projection.test.ts b/apps/sim/lib/copilot/integration-tool-projection.test.ts new file mode 100644 index 00000000000..c12eb30732f --- /dev/null +++ b/apps/sim/lib/copilot/integration-tool-projection.test.ts @@ -0,0 +1,175 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/blocks/registry-maps', () => ({ + BLOCK_REGISTRY: { + slack: { + type: 'slack', + tools: { + access: ['slack_message_v1', 'slack_canvas_v1'], + config: { + tool: ({ operation }: { operation?: string }) => + operation === 'canvas' ? 'slack_canvas_v1' : 'slack_message_v1', + }, + }, + subBlocks: [ + { + id: 'operation', + type: 'dropdown', + options: [ + { label: 'Send Message', id: 'send' }, + { label: 'Create Canvas', id: 'canvas' }, + ], + }, + ], + }, + gmail: { + type: 'gmail', + tools: { access: ['gmail_send_v1'] }, + subBlocks: [], + }, + /** + * Multi-tool block with no operation selector: its operation ids ARE its + * tool ids, so there are no dropdown options to filter. + */ + sqs: { + type: 'sqs', + tools: { access: ['sqs_send_v1', 'sqs_receive_v1'] }, + subBlocks: [], + }, + }, +})) + +vi.mock('@/tools/registry', () => ({ + tools: { + slack_message_v1: { name: 'Send Message' }, + slack_canvas_v1: { name: 'Create Canvas' }, + gmail_send_v1: { name: 'Send Email' }, + sqs_send_v1: { name: 'Send' }, + sqs_receive_v1: { name: 'Receive' }, + }, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + getAllowedIntegrationsFromEnv: () => null, +})) + +vi.mock('@/lib/integrations/availability.server', () => ({ + isIntegrationDeploymentAvailableForVisibility: () => true, +})) + +import { + projectIntegrationToolsForViewer, + resolveDeniedBlockOperations, +} from '@/lib/copilot/integration-tool-projection' +import { resetExposedIntegrationToolsCache } from '@/lib/copilot/integration-tools' + +function toolIds(config: Parameters[1]): string[] { + return projectIntegrationToolsForViewer(null, config) + .tools.map((tool) => tool.toolId) + .sort() +} + +describe('projectIntegrationToolsForViewer', () => { + beforeEach(() => { + resetExposedIntegrationToolsCache() + }) + + it('exposes everything to a viewer with no permission group', () => { + expect(toolIds(null)).toEqual([ + 'gmail_send_v1', + 'slack_canvas_v1', + 'slack_message_v1', + 'sqs_receive_v1', + 'sqs_send_v1', + ]) + }) + + it('withholds a tool the group denies while keeping its siblings', () => { + expect(toolIds({ allowedIntegrations: null, deniedTools: ['slack_canvas_v1'] })).toEqual([ + 'gmail_send_v1', + 'slack_message_v1', + 'sqs_receive_v1', + 'sqs_send_v1', + ]) + }) + + it('applies the block allowlist and the tool denylist together', () => { + expect(toolIds({ allowedIntegrations: ['slack'], deniedTools: ['slack_canvas_v1'] })).toEqual([ + 'slack_message_v1', + ]) + }) + + it('reports the allowed block types and the tool gate it applied', () => { + const projection = projectIntegrationToolsForViewer(null, { + allowedIntegrations: ['Slack'], + deniedTools: ['slack_canvas_v1'], + }) + + expect(projection.allowedBlockTypes).toEqual(new Set(['slack'])) + expect(projection.isToolAllowed('slack_canvas_v1')).toBe(false) + expect(projection.isToolAllowed('slack_message_v1')).toBe(true) + }) + + it('leaves the gate unrestricted when the group denies nothing', () => { + const projection = projectIntegrationToolsForViewer(null, { + allowedIntegrations: null, + deniedTools: [], + }) + + expect(projection.allowedBlockTypes).toBeNull() + expect(projection.isToolAllowed('slack_canvas_v1')).toBe(true) + }) +}) + +describe('resolveDeniedBlockOperations', () => { + const allow = (denied: string[]) => (toolId: string) => !denied.includes(toolId) + + it('does no work when the group denies nothing', () => { + const resolved = resolveDeniedBlockOperations([], allow([])) + + expect(resolved.needsProjection.size).toBe(0) + expect(resolved.fullyDenied.size).toBe(0) + }) + + it('reports the operation ids to withhold from a partly denied block', () => { + const denied = ['slack_canvas_v1'] + const resolved = resolveDeniedBlockOperations(denied, allow(denied)) + + expect(resolved.needsProjection.get('slack')).toEqual(new Set(['canvas'])) + expect(resolved.fullyDenied.has('slack')).toBe(false) + }) + + it('withholds a block whose every operation is denied', () => { + const denied = ['slack_message_v1', 'slack_canvas_v1'] + const resolved = resolveDeniedBlockOperations(denied, allow(denied)) + + expect(resolved.fullyDenied.has('slack')).toBe(true) + expect(resolved.needsProjection.has('slack')).toBe(false) + }) + + it('withholds a single-tool block whose only tool is denied', () => { + const denied = ['gmail_send_v1'] + const resolved = resolveDeniedBlockOperations(denied, allow(denied)) + + expect(resolved.fullyDenied.has('gmail')).toBe(true) + }) + + it('reprojects a selector-less block so its tool list drops the denied id', () => { + const denied = ['sqs_receive_v1'] + const resolved = resolveDeniedBlockOperations(denied, allow(denied)) + + expect(resolved.needsProjection.get('sqs')).toEqual(new Set()) + expect(resolved.fullyDenied.has('sqs')).toBe(false) + }) + + it('ignores blocks that own no denied tool', () => { + const denied = ['slack_canvas_v1'] + const resolved = resolveDeniedBlockOperations(denied, allow(denied)) + + expect(resolved.needsProjection.has('gmail')).toBe(false) + expect(resolved.needsProjection.has('sqs')).toBe(false) + }) +}) diff --git a/apps/sim/lib/copilot/integration-tool-projection.ts b/apps/sim/lib/copilot/integration-tool-projection.ts new file mode 100644 index 00000000000..ccf89005fe7 --- /dev/null +++ b/apps/sim/lib/copilot/integration-tool-projection.ts @@ -0,0 +1,140 @@ +import type { ExposedIntegrationTool } from '@/lib/copilot/integration-tools' +import { + filterExposedIntegrationTools, + getExposedIntegrationTools, +} from '@/lib/copilot/integration-tools' +import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { + intersectIntegrationAllowlists, + toAllowedIntegrationTypes, +} from '@/lib/permission-groups/integration-allowlist' +import { + collectDeniedOperationIds, + createToolAccessGate, + getOperationOptionIds, + type IsToolAllowed, + NO_DENIED_OPERATIONS, +} from '@/lib/permission-groups/operation-access' +import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { BLOCK_REGISTRY } from '@/blocks/registry-maps' + +/** The slice of a permission group the integration gate reads. */ +export type IntegrationGateConfig = Pick< + PermissionGroupConfig, + 'allowedIntegrations' | 'deniedTools' +> + +/** Everything a surface needs to show a viewer only the integrations they may use. */ +export interface ViewerIntegrationProjection { + /** The exposed tools this viewer may discover and call. */ + tools: ExposedIntegrationTool[] + /** + * Lowercased block types the viewer may use; `null` when unrestricted. Held + * separately because block-owned VFS files are gated on the block, not on a + * tool. + */ + allowedBlockTypes: ReadonlySet | null + /** The group's per-tool denylist, for surfaces that gate operations themselves. */ + isToolAllowed: IsToolAllowed +} + +/** + * The viewer's projection of the exposed integration-tool universe: block + * visibility, deployment availability, the workspace + env integration + * allowlists, and the group's per-tool `deniedTools` denylist, applied together. + * + * The single entry point for every surface that shows the agent what it may use + * — VFS stamping, `list_integration_tools`, and the deferred callable-tool + * payload — so a tool an admin denied cannot be advertised on one surface after + * being withheld on another, and no surface can forget a gate the others apply. + */ +export function projectIntegrationToolsForViewer( + vis: BlockVisibilityState | null, + permissionConfig: IntegrationGateConfig | null | undefined +): ViewerIntegrationProjection { + const allowedBlockTypes = toAllowedIntegrationTypes( + intersectIntegrationAllowlists( + permissionConfig?.allowedIntegrations ?? null, + getAllowedIntegrationsFromEnv() + ) + ) + const isToolAllowed = createToolAccessGate(permissionConfig?.deniedTools) + + const tools = filterExposedIntegrationTools( + getExposedIntegrationTools(), + vis, + (owner) => + isIntegrationDeploymentAvailableForVisibility(owner.blockType, vis) && + (allowedBlockTypes === null || allowedBlockTypes.has(owner.blockType.toLowerCase())), + isToolAllowed + ) + + return { tools, allowedBlockTypes, isToolAllowed } +} + +/** What a viewer's `deniedTools` denylist costs the block schemas they are shown. */ +export interface DeniedBlockOperations { + /** + * Block type -> the operation ids to withhold, for every block that owns a + * denied tool and is still worth publishing. The set is empty for a block + * that declares no operation selector: it has no option to remove, but its + * `tools` list still has to lose the denied id. + */ + needsProjection: ReadonlyMap> + /** Block types with nothing left to configure, withheld entirely. */ + fullyDenied: ReadonlySet +} + +const NO_DENIED_BLOCK_OPERATIONS: DeniedBlockOperations = { + needsProjection: new Map(), + fullyDenied: new Set(), +} + +/** + * Resolves what a viewer's per-tool denylist removes from the block schemas. + * + * Read off the raw registry rather than the exposed-tool set, because + * `deniedTools` holds `tools.access` ids verbatim: a denied superseded version + * has to resolve against the block that declares it, not only against the + * latest one. Only blocks that actually own a denied tool are inspected, and + * the pass is skipped outright when nothing is denied — the common case. + * + * `deniedTools` is read only to detect that common case; every actual decision + * goes through `isToolAllowed`, so the two arguments cannot disagree. + */ +export function resolveDeniedBlockOperations( + deniedTools: readonly string[] | undefined, + isToolAllowed: IsToolAllowed +): DeniedBlockOperations { + if (!deniedTools?.length) return NO_DENIED_BLOCK_OPERATIONS + + const needsProjection = new Map>() + const fullyDenied = new Set() + + for (const block of Object.values(BLOCK_REGISTRY)) { + const access = block.tools?.access + if (!access?.length || access.every(isToolAllowed)) continue + + if (!access.some(isToolAllowed)) { + fullyDenied.add(block.type) + continue + } + + const options = getOperationOptionIds(block) + if (!options.length) { + needsProjection.set(block.type, NO_DENIED_OPERATIONS) + continue + } + + const deniedOperations = collectDeniedOperationIds(block, options, isToolAllowed) + if (deniedOperations.size === options.length) { + fullyDenied.add(block.type) + continue + } + needsProjection.set(block.type, deniedOperations) + } + + return { needsProjection, fullyDenied } +} diff --git a/apps/sim/lib/copilot/integration-tools.test.ts b/apps/sim/lib/copilot/integration-tools.test.ts index bbfb423aecc..edc4c8555b2 100644 --- a/apps/sim/lib/copilot/integration-tools.test.ts +++ b/apps/sim/lib/copilot/integration-tools.test.ts @@ -39,6 +39,9 @@ import { resetExposedIntegrationToolsCache, } from '@/lib/copilot/integration-tools' +const allowAllOwners = () => true +const allowAllTools = () => true + describe('getExposedIntegrationTools', () => { beforeEach(() => { resetExposedIntegrationToolsCache() @@ -53,7 +56,12 @@ describe('getExposedIntegrationTools', () => { }) it('exposes shared tools to viewers without the preview reveal, but not preview-only tools', () => { - const visible = filterExposedIntegrationTools(getExposedIntegrationTools(), null) + const visible = filterExposedIntegrationTools( + getExposedIntegrationTools(), + null, + allowAllOwners, + allowAllTools + ) expect(visible.some((t) => t.toolId === 'svc_send_v2')).toBe(true) expect(visible.some((t) => t.toolId === 'newsvc_do_v1')).toBe(false) }) @@ -67,7 +75,8 @@ describe('getExposedIntegrationTools', () => { const visible = filterExposedIntegrationTools( getExposedIntegrationTools(), vis, - (owner) => owner.blockType !== 'svc' + (owner) => owner.blockType !== 'svc', + allowAllTools ) const send = visible.find((tool) => tool.toolId === 'svc_send_v2') @@ -75,6 +84,17 @@ describe('getExposedIntegrationTools', () => { expect(send?.preview).toBe(true) }) + it("drops a tool the viewer's permission group denies outright", () => { + const visible = filterExposedIntegrationTools( + getExposedIntegrationTools(), + null, + allowAllOwners, + (toolId) => toolId !== 'svc_send_v2' + ) + + expect(visible.some((t) => t.toolId === 'svc_send_v2')).toBe(false) + }) + it('exposes only the latest version of each tool', () => { const exposed = getExposedIntegrationTools() expect(exposed.some((t) => t.toolId === 'svc_send_v1')).toBe(false) diff --git a/apps/sim/lib/copilot/integration-tools.ts b/apps/sim/lib/copilot/integration-tools.ts index aafadc452b7..cae7704fb7f 100644 --- a/apps/sim/lib/copilot/integration-tools.ts +++ b/apps/sim/lib/copilot/integration-tools.ts @@ -1,4 +1,5 @@ import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' +import type { IsToolAllowed } from '@/lib/permission-groups/operation-access' import { BLOCK_REGISTRY } from '@/blocks/registry-maps' import { isHiddenUnder } from '@/blocks/visibility/context' import { tools as toolRegistry } from '@/tools/registry' @@ -103,15 +104,21 @@ export function getExposedIntegrationTools(): ExposedIntegrationTool[] { /** * Per-viewer projection of the exposed set: drops tools whose owning block is * hidden under `vis` (unrevealed preview blocks — including with a null state — - * and kill-switched types). Apply at every surface that hands the set to a - * viewer: VFS stamping, the deferred tool payload, `list_integration_tools`. + * and kill-switched types), and tools the viewer's permission group denies + * outright. Both gates are required rather than defaulted: a surface that + * applied only one of them would advertise tools its viewer cannot use, so the + * omission is a compile error instead of a convention. Call + * {@link projectIntegrationToolsForViewer}, which resolves both from a + * permission config. */ export function filterExposedIntegrationTools( tools: ExposedIntegrationTool[], vis: BlockVisibilityState | null, - isOwnerAllowed: (owner: ExposedIntegrationToolOwner) => boolean = () => true + isOwnerAllowed: (owner: ExposedIntegrationToolOwner) => boolean, + isToolAllowed: IsToolAllowed ): ExposedIntegrationTool[] { return tools.flatMap((tool) => { + if (!isToolAllowed(tool.toolId)) return [] const owner = tool.owners.find( (candidate) => !isHiddenUnder(vis, { type: candidate.blockType, preview: candidate.preview }) && diff --git a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts index f05edb4dcf1..6192fd377e3 100644 --- a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts +++ b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts @@ -1,12 +1,6 @@ import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' -import { - filterExposedIntegrationTools, - getExposedIntegrationTools, -} from '@/lib/copilot/integration-tools' +import { projectIntegrationToolsForViewer } from '@/lib/copilot/integration-tool-projection' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' -import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { stripVersionSuffix } from '@/tools/utils' @@ -25,17 +19,7 @@ export async function executeListIntegrationTools( const permissionConfig = context.workspaceId ? await getUserPermissionConfig(context.userId, context.workspaceId) : null - const allowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) - const all = filterExposedIntegrationTools( - getExposedIntegrationTools(), - vis, - (owner) => - isIntegrationDeploymentAvailableForVisibility(owner.blockType, vis) && - (allowedIntegrations === null || allowedIntegrations.includes(owner.blockType.toLowerCase())) - ) + const { tools: all } = projectIntegrationToolsForViewer(vis, permissionConfig) const service = stripVersionSuffix(raw.toLowerCase()) const matches = all.filter((tool) => tool.service === service) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts index e83beb2ba4c..cefbe354c40 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts @@ -86,6 +86,88 @@ describe('get blocks metadata', () => { expect(result.metadata).toHaveProperty('slack') }) + /** + * A two-operation block standing in for a real integration: the projection + * resolves each operation to a tool id through `tools.config.tool`, which is + * what the group's denylist is written against. + */ + const gatedBlock = { + type: 'slack', + name: 'Slack', + description: 'Send messages.', + category: 'tools', + bgColor: '#000000', + icon: () => null, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Send Message', id: 'send' }, + { label: 'Create Canvas', id: 'canvas' }, + ], + }, + ], + tools: { + access: ['slack_message', 'slack_canvas'], + config: { + tool: ({ operation }: { operation?: string }) => + operation === 'canvas' ? 'slack_canvas' : 'slack_message', + }, + }, + inputs: {}, + outputs: {}, + } as unknown as BlockConfig + + it('withholds an operation whose tool the group denies', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + deniedTools: ['slack_canvas'], + }) + vi.mocked(getBlock).mockReturnValue(gatedBlock) + + const result = await getBlocksMetadataServerTool.execute( + { blockIds: ['slack'] }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + const slack = result.metadata.slack as { operations: Record } + + expect(Object.keys(slack.operations)).toEqual(['send']) + }) + + it('leaves the projection untouched when the group denies nothing', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + deniedTools: [], + }) + vi.mocked(getBlock).mockReturnValue(gatedBlock) + + const result = await getBlocksMetadataServerTool.execute( + { blockIds: ['slack'] }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + const slack = result.metadata.slack as { operations: Record } + expect(Object.keys(slack.operations).sort()).toEqual(['canvas', 'send']) + }) + + it('withholds a block whose every operation the group denies', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + deniedTools: ['slack_message', 'slack_canvas'], + }) + vi.mocked(getBlock).mockReturnValue(gatedBlock) + + const result = await getBlocksMetadataServerTool.execute( + { blockIds: ['slack'] }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.metadata).not.toHaveProperty('slack') + }) + it('keeps access-control-exempt and special blocks under a restrictive allowlist', async () => { const result = await getBlocksMetadataServerTool.execute( { blockIds: ['start_trigger', 'loop', 'slack', 'notion'] }, diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 301116d7c7e..9c937fad0fe 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'fs' import { join } from 'path' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { omit } from '@sim/utils/object' import { z } from 'zod' import { type CatalogBlockDetail, @@ -18,6 +19,11 @@ import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integration import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { + createToolAccessGate, + type IsToolAllowed, + OPERATION_SUBBLOCK_ID, +} from '@/lib/permission-groups/operation-access' import { getBlock } from '@/blocks/registry' import { AuthMode, type BlockConfig, type SubBlockConfig } from '@/blocks/types' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' @@ -116,6 +122,57 @@ function toCopilotBlockMetadata(detail: CatalogBlockDetail): CopilotBlockMetadat }) as CopilotBlockMetadata } +/** + * Strips everything a denied tool id reaches in one block's metadata: the tool + * entry, every operation that runs it, that operation's input schema, and the + * selector option that would choose it. + * + * Returns the projection untouched when the group denies nothing this block + * owns, so an unrestricted viewer pays one pass over `operations` and nothing + * else. `null` means the block has no usable operation left and should be + * withheld entirely, matching the VFS projection. + */ +function withDeniedToolsRemoved( + metadata: CopilotBlockMetadata, + isToolAllowed: IsToolAllowed +): CopilotBlockMetadata | null { + const operations = metadata.operations ?? {} + const deniedOperations = new Set() + for (const [operationId, operation] of Object.entries(operations)) { + if (operation.toolId && !isToolAllowed(operation.toolId)) deniedOperations.add(operationId) + } + const tools = metadata.tools.filter((tool) => isToolAllowed(tool.id)) + if (deniedOperations.size === 0 && tools.length === metadata.tools.length) return metadata + + const allToolsDenied = metadata.tools.length > 0 && tools.length === 0 + const allOperationsDenied = + Object.keys(operations).length > 0 && deniedOperations.size === Object.keys(operations).length + if (allToolsDenied || allOperationsDenied) return null + + return { + ...metadata, + tools, + /* `removeNullish` drops an empty projection, so neither schema is + guaranteed present. */ + ...(metadata.inputSchema + ? { + inputSchema: metadata.inputSchema.map((field) => + field.id === OPERATION_SUBBLOCK_ID && Array.isArray(field.options) + ? { + ...field, + options: field.options.filter((option) => !deniedOperations.has(option.id)), + } + : field + ), + } + : {}), + operations: omit(operations, [...deniedOperations]), + ...(metadata.operationInputSchema + ? { operationInputSchema: omit(metadata.operationInputSchema, [...deniedOperations]) } + : {}), + } +} + export const getBlocksMetadataServerTool: BaseServerTool< z.infer, z.infer @@ -138,6 +195,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() ) + const isToolAllowed = createToolAccessGate(permissionConfig?.deniedTools) const visibility = overlayVisibility() const result: Record = {} @@ -219,6 +277,13 @@ export const getBlocksMetadataServerTool: BaseServerTool< }) continue } + + const permitted = withDeniedToolsRemoved(metadata, isToolAllowed) + if (!permitted) { + logger.debug('Block has no operation this permission group allows', { blockId }) + continue + } + metadata = permitted } try { diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 370485eb66f..308e29c9766 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -391,6 +391,57 @@ describe('hosted-key VFS metadata', () => { }) }) +describe('serializeBlockSchema permission-group gating', () => { + const slackBlock = { + type: 'slack', + name: 'Slack', + description: 'Slack', + category: 'tools', + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Send Message', id: 'send' }, + { label: 'Create Canvas', id: 'canvas' }, + ], + }, + ], + tools: { access: ['slack_message', 'slack_canvas'] }, + inputs: {}, + outputs: {}, + } as unknown as BlockConfig + + it('publishes every operation and tool when nothing is denied', () => { + const schema = JSON.parse(serializeBlockSchema(slackBlock)) + + expect(schema.tools).toEqual(['slack_message', 'slack_canvas']) + expect(schema.subBlocks[0].options.map((option: { id: string }) => option.id)).toEqual([ + 'send', + 'canvas', + ]) + }) + + it('withholds denied operations and tool ids from the viewer schema', () => { + const schema = JSON.parse( + serializeBlockSchema(slackBlock, { + deniedOperationIds: new Set(['canvas']), + isToolAllowed: (toolId: string) => toolId !== 'slack_canvas', + }) + ) + + expect(schema.tools).toEqual(['slack_message']) + expect(schema.subBlocks[0].options).toEqual([{ label: 'Send Message', id: 'send' }]) + }) + + it('leaves the shared registry options array untouched', () => { + serializeBlockSchema(slackBlock, { deniedOperationIds: new Set(['canvas']) }) + + expect(slackBlock.subBlocks[0].options).toHaveLength(2) + }) +}) + describe('serializeKBMeta', () => { const baseKb = { id: 'kb-1', diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 2f59b4e79f3..212a8ad674d 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -14,6 +14,7 @@ import { import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' +import { type IsToolAllowed, OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' @@ -96,6 +97,18 @@ export interface ComponentSerializationOptions { reason: string } > + /** + * The viewer's permission-group tool gate. Denied tool ids are dropped from + * `tools` and `toolAuth` so the agent is never handed an id it may not call. + */ + isToolAllowed?: IsToolAllowed + /** + * Operation ids the viewer's permission group denies, removed from the + * operation selector's options. Paired with `isToolAllowed` rather than + * derived here so the caller — which also decides whether a wholly denied + * block is worth publishing at all — resolves them exactly once. + */ + deniedOperationIds?: ReadonlySet } /** @@ -635,8 +648,18 @@ export function serializeBlockSchema( .filter((id) => !visibleIds.has(id)) ) + const deniedOperationIds = options?.deniedOperationIds const subBlocks = visibleSubBlocks.map((sb) => { const serialized = serializeSubBlock(sb) + if ( + sb.id === OPERATION_SUBBLOCK_ID && + deniedOperationIds?.size && + Array.isArray(serialized.options) + ) { + serialized.options = (serialized.options as Array<{ id: string }>).filter( + (option) => !deniedOperationIds.has(option.id) + ) + } const restriction = options?.restrictedInputs?.get(sb.id) if (restriction) { serialized.readOnly = true @@ -652,8 +675,13 @@ export function serializeBlockSchema( return serialized }) + const isToolAllowed = options?.isToolAllowed + const accessibleTools = isToolAllowed + ? block.tools.access.filter((toolId) => isToolAllowed(toolId)) + : block.tools.access + const toolAuth: Record = {} - for (const toolId of block.tools.access) { + for (const toolId of accessibleTools) { const tool = options?.toolConfigs?.get(toolId) if (!tool) continue const auth = serializeToolAuth(tool, hosted, block.type) @@ -708,7 +736,7 @@ export function serializeBlockSchema( // configures. Hiding it keeps the block self-contained (fields in, outputs // out) so the agent doesn't treat it like the generic workflow block and // ask for a workflowId/inputMapping. - tools: isCustomBlockType(block.type) ? [] : block.tools.access, + tools: isCustomBlockType(block.type) ? [] : accessibleTools, toolAuth: Object.keys(toolAuth).length > 0 ? toolAuth : undefined, subBlocks, inputs, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 8b57bb21a48..ccaa1239a73 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -26,9 +26,13 @@ import { import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' +import { + type DeniedBlockOperations, + projectIntegrationToolsForViewer, + resolveDeniedBlockOperations, +} from '@/lib/copilot/integration-tool-projection' import { type ExposedIntegrationTool, - filterExposedIntegrationTools, getExposedIntegrationTools, } from '@/lib/copilot/integration-tools' import { recordVfsMaterialize } from '@/lib/copilot/request/metrics' @@ -139,7 +143,11 @@ import { import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { + intersectIntegrationAllowlists, + toAllowedIntegrationTypes, +} from '@/lib/permission-groups/integration-allowlist' +import type { IsToolAllowed } from '@/lib/permission-groups/operation-access' import { listOrganizationWorkspaceRefs, listPermissionGroupRoster, @@ -286,42 +294,60 @@ const triggerPathOwners = new Map, vis: BlockVisibilityState | null, - allowedIntegrationTypes: ReadonlySet | null + gate: StaticFileGate ): boolean { const config = BLOCK_REGISTRY[owner.type] if (config?.hideFromToolbar) return true if (!isIntegrationDeploymentAvailableForVisibility(owner.type, vis)) return true if ( - allowedIntegrationTypes !== null && + gate.allowedIntegrationTypes !== null && !isBlockTypeAccessControlExempt(owner.type) && - !allowedIntegrationTypes.has(owner.type.toLowerCase()) + !gate.allowedIntegrationTypes.has(owner.type.toLowerCase()) ) { return true } + /* Every operation denied leaves nothing the viewer could configure, so the + block is withheld outright rather than published with an empty selector. */ + if (gate.fullyDeniedBlockTypes.has(owner.type)) return true return isHiddenUnder(vis, owner) } +/** + * The per-viewer gates the static-file filter applies, carried together so a + * caller cannot pass one and forget the other. + */ +interface StaticFileGate { + /** Lowercased block types the viewer may use; `null` when unrestricted. */ + allowedIntegrationTypes: ReadonlySet | null + /** Block types whose every selectable operation the viewer's group denies. */ + fullyDeniedBlockTypes: ReadonlySet +} + +const UNGATED_STATIC_FILES: StaticFileGate = { + allowedIntegrationTypes: null, + fullyDeniedBlockTypes: new Set(), +} + function isStaticFileHidden( path: string, vis: BlockVisibilityState | null, - allowedIntegrationTypes: ReadonlySet | null = null + gate: StaticFileGate = UNGATED_STATIC_FILES ): boolean { const blockMatch = path.match(/^components\/(?:blocks|triggers\/sim)\/([^/]+)\.json$/) if (blockMatch) { const config = BLOCK_REGISTRY[blockMatch[1]!] - return config ? isBlockOwnerHidden(config, vis, allowedIntegrationTypes) : false + return config ? isBlockOwnerHidden(config, vis, gate) : false } const triggerOwners = triggerPathOwners.get(path) if (triggerOwners) { return ( triggerOwners.length > 0 && - triggerOwners.every((owner) => isBlockOwnerHidden(owner, vis, allowedIntegrationTypes)) + triggerOwners.every((owner) => isBlockOwnerHidden(owner, vis, gate)) ) } const owners = integrationPathOwners.get(path) return owners - ? owners.length > 0 && - owners.every((owner) => isBlockOwnerHidden(owner, vis, allowedIntegrationTypes)) + ? owners.length > 0 && owners.every((owner) => isBlockOwnerHidden(owner, vis, gate)) : false } @@ -366,20 +392,13 @@ function buildIntegrationAggregateFiles( ]) } -function buildTriggerOverview( - vis: BlockVisibilityState | null, - allowedIntegrationTypes: ReadonlySet | null -): string { +function buildTriggerOverview(vis: BlockVisibilityState | null, gate: StaticFileGate): string { const builtinTriggers = Object.values(BLOCK_REGISTRY) .filter( (block) => block.category === 'triggers' && !block.preview && - !isStaticFileHidden( - `components/triggers/sim/${block.type}.json`, - vis, - allowedIntegrationTypes - ) + !isStaticFileHidden(`components/triggers/sim/${block.type}.json`, vis, gate) ) .map((block) => ({ id: block.type, @@ -390,11 +409,7 @@ function buildTriggerOverview( const externalTriggers = Object.entries(TRIGGER_REGISTRY) .filter( ([id, trigger]) => - !isStaticFileHidden( - `components/triggers/${trigger.provider}/${id}.json`, - vis, - allowedIntegrationTypes - ) + !isStaticFileHidden(`components/triggers/${trigger.provider}/${id}.json`, vis, gate) ) .map(([id, trigger]) => ({ id, @@ -421,6 +436,65 @@ function isBinaryDocBuffer(buffer: Buffer, ext: string): boolean { return buffer.subarray(0, 2).toString('latin1') === 'PK' } +/** + * Tool configs keyed by every id a block schema may reference, memoized for the + * process. Shared by the one-time static build and the per-viewer re-projection + * of a block whose operations are partly denied. + */ +let staticToolConfigs: ReadonlyMap | null = null + +function getStaticToolConfigs(): ReadonlyMap { + if (staticToolConfigs) return staticToolConfigs + const configs = new Map() + for (const { toolId, config } of getExposedIntegrationTools()) { + configs.set(toolId, config) + configs.set(config.id, config) + } + staticToolConfigs = configs + return configs +} + +const BLOCK_SCHEMA_PATH_PREFIX = 'components/blocks/' + +/** The per-viewer projections applied to a shared static component file. */ +interface StaticFileProjection { + sandboxEntitled: boolean + deniedOperations: DeniedBlockOperations + isToolAllowed: IsToolAllowed +} + +/** + * The viewer's copy of one shared static component file. + * + * Returns the shared string untouched unless this viewer actually loses + * something, so the process-global build stays the hot path and only a block + * carrying a denied operation pays for a re-serialization. + */ +function projectStaticComponentFile( + path: string, + content: string, + projection: StaticFileProjection +): string { + if (path === 'components/blocks/function.json' && !projection.sandboxEntitled) { + return staticFunctionSchemaWithRestrictedSimSandboxes ?? content + } + if (projection.deniedOperations.needsProjection.size === 0) return content + if (!path.startsWith(BLOCK_SCHEMA_PATH_PREFIX)) return content + + const blockType = path.match(/^components\/blocks\/([^/]+)\.json$/)?.[1] + if (!blockType) return content + const deniedOperationIds = projection.deniedOperations.needsProjection.get(blockType) + if (!deniedOperationIds) return content + const block = BLOCK_REGISTRY[blockType] + if (!block) return content + + return serializeBlockSchema(block, { + toolConfigs: getStaticToolConfigs(), + deniedOperationIds, + isToolAllowed: projection.isToolAllowed, + }) +} + /** * Build the static component files from block and tool registries. * This only needs to happen once per process. @@ -442,11 +516,7 @@ function getStaticComponentFiles(): Map { const allBlocks = Object.values(BLOCK_REGISTRY) const visibleBlocks = allBlocks.filter((block) => !block.hideFromToolbar) const exposedTools = getExposedIntegrationTools() - const toolConfigs = new Map() - for (const { toolId, config } of exposedTools) { - toolConfigs.set(toolId, config) - toolConfigs.set(config.id, config) - } + const toolConfigs = getStaticToolConfigs() let blocksFiltered = 0 for (const block of visibleBlocks) { @@ -592,7 +662,7 @@ function getStaticComponentFiles(): Map { externalTriggerCount++ } - files.set('components/triggers/triggers.md', buildTriggerOverview(null, null)) + files.set('components/triggers/triggers.md', buildTriggerOverview(null, UNGATED_STATIC_FILES)) logger.info('Static component files built', { blocks: visibleBlocks.length, @@ -960,29 +1030,28 @@ export class WorkspaceVFS { // Per-viewer gating happens HERE, not in the shared builder: files // owned by blocks hidden for this viewer are skipped at stamp time. - const configuredAllowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() + const { + tools: viewerIntegrationTools, + allowedBlockTypes, + isToolAllowed, + } = projectIntegrationToolsForViewer(blockVisibility, permissionConfig) + const deniedOperations = resolveDeniedBlockOperations( + permissionConfig?.deniedTools, + isToolAllowed ) - const allowedIntegrationTypes = configuredAllowedIntegrations - ? new Set(configuredAllowedIntegrations.map((type) => type.toLowerCase())) - : null + const staticFileGate: StaticFileGate = { + allowedIntegrationTypes: allowedBlockTypes, + fullyDeniedBlockTypes: deniedOperations.fullyDenied, + } + const staticFileProjection: StaticFileProjection = { + sandboxEntitled, + deniedOperations, + isToolAllowed, + } for (const [path, content] of getStaticComponentFiles()) { - if (isStaticFileHidden(path, blockVisibility, allowedIntegrationTypes)) continue - const projectedContent = - path === 'components/blocks/function.json' && !sandboxEntitled - ? (staticFunctionSchemaWithRestrictedSimSandboxes ?? content) - : content - this.files.set(path, projectedContent) + if (isStaticFileHidden(path, blockVisibility, staticFileGate)) continue + this.files.set(path, projectStaticComponentFile(path, content, staticFileProjection)) } - const viewerIntegrationTools = filterExposedIntegrationTools( - getExposedIntegrationTools(), - blockVisibility, - (owner) => - isIntegrationDeploymentAvailableForVisibility(owner.blockType, blockVisibility) && - (allowedIntegrationTypes === null || - allowedIntegrationTypes.has(owner.blockType.toLowerCase())) - ) for (const exposedTool of viewerIntegrationTools) { const { config: tool, service, operation, blockType } = exposedTool this.files.set( @@ -999,7 +1068,7 @@ export class WorkspaceVFS { } this.files.set( 'components/triggers/triggers.md', - buildTriggerOverview(blockVisibility, allowedIntegrationTypes) + buildTriggerOverview(blockVisibility, staticFileGate) ) span.setAttributes({ @@ -3104,14 +3173,13 @@ export class WorkspaceVFS { getPersonalAndWorkspaceEnv(userId, workspaceId), permissionConfigPromise, ]) - const configuredAllowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) const credentialVisibility = createIntegrationCredentialVisibility({ - allowedIntegrationTypes: configuredAllowedIntegrations - ? new Set(configuredAllowedIntegrations.map((type) => type.toLowerCase())) - : null, + allowedIntegrationTypes: toAllowedIntegrationTypes( + intersectIntegrationAllowlists( + permissionConfig?.allowedIntegrations ?? null, + getAllowedIntegrationsFromEnv() + ) + ), blockVisibility, }) const visibleOAuthCredentials = oauthCredentials.filter((credential) => diff --git a/apps/sim/lib/permission-groups/integration-allowlist.ts b/apps/sim/lib/permission-groups/integration-allowlist.ts index 3656ee60f6d..dd38b4ce07d 100644 --- a/apps/sim/lib/permission-groups/integration-allowlist.ts +++ b/apps/sim/lib/permission-groups/integration-allowlist.ts @@ -15,3 +15,15 @@ export function intersectIntegrationAllowlists( const secondSet = new Set(normalizedSecond) return normalizedFirst.filter((integration) => secondSet.has(integration)) } + +/** + * The lowercased block types an allowlist permits, indexed for membership tests. + * `null` stays `null` — unrestricted, not "nothing allowed". + */ +export function toAllowedIntegrationTypes( + allowedIntegrations: readonly string[] | null +): ReadonlySet | null { + return allowedIntegrations + ? new Set(allowedIntegrations.map((integration) => integration.toLowerCase())) + : null +} diff --git a/apps/sim/lib/permission-groups/model-access.test.ts b/apps/sim/lib/permission-groups/model-access.test.ts new file mode 100644 index 00000000000..d525c27b8e4 --- /dev/null +++ b/apps/sim/lib/permission-groups/model-access.test.ts @@ -0,0 +1,37 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { createModelAccessGate } from '@/lib/permission-groups/model-access' + +describe('createModelAccessGate', () => { + it('allows everything when the group restricts nothing', () => { + const gate = createModelAccessGate(null) + expect(gate('gpt-4o')).toBe(true) + expect(createModelAccessGate({ deniedModels: [], allowedModelProviders: null })).toBe(gate) + }) + + it('denies a listed model case-insensitively', () => { + const gate = createModelAccessGate({ deniedModels: ['GPT-4o'], allowedModelProviders: null }) + expect(gate('gpt-4o')).toBe(false) + expect(gate('claude-sonnet-4-5')).toBe(true) + }) + + it('denies a model whose provider is not allowlisted', () => { + const gate = createModelAccessGate({ deniedModels: [], allowedModelProviders: ['anthropic'] }) + expect(gate('gpt-4o')).toBe(false) + expect(gate('claude-sonnet-4-5')).toBe(true) + }) + + it('leaves an id that resolves to no chat provider to the denylist alone', () => { + const gate = createModelAccessGate({ + deniedModels: ['eleven_multilingual_v2'], + allowedModelProviders: ['anthropic'], + }) + + /* A speech/image/video id is not a provider choice, so the provider + allowlist has nothing to say about it — only the denylist does. */ + expect(gate('eleven_turbo_v2_5')).toBe(true) + expect(gate('eleven_multilingual_v2')).toBe(false) + }) +}) diff --git a/apps/sim/lib/permission-groups/model-access.ts b/apps/sim/lib/permission-groups/model-access.ts new file mode 100644 index 00000000000..cff9a1a76dc --- /dev/null +++ b/apps/sim/lib/permission-groups/model-access.ts @@ -0,0 +1,35 @@ +import { findProviderFromModel } from '@/providers/utils' +import type { PermissionGroupConfig } from './types' + +/** Decides whether the caller's permission group allows a concrete model id. */ +export type IsModelUsable = (model: string) => boolean + +/** Shared allow-everything gate, so the unrestricted case allocates nothing. */ +const ALLOW_ALL_MODELS: IsModelUsable = () => true + +/** The slice of a permission group the model gate reads. */ +export type ModelGateConfig = Pick + +/** + * The model gate for a resolved permission-group config: the `deniedModels` + * denylist, then the `allowedModelProviders` allowlist. + * + * Only chat models resolve to a provider. A `model` field holding an embedding, + * speech, image or video id is not a provider choice, so the provider allowlist + * has nothing to say about it — judging it anyway would read every such id as + * Ollama and reject it. + */ +export function createModelAccessGate(config: ModelGateConfig | null | undefined): IsModelUsable { + const deniedModels = config?.deniedModels + const allowedProviders = config?.allowedModelProviders ?? null + if (!deniedModels?.length && allowedProviders === null) return ALLOW_ALL_MODELS + + const denied = new Set(deniedModels?.map((model) => model.toLowerCase())) + return (model: string) => { + if (denied.has(model.toLowerCase())) return false + if (allowedProviders === null) return true + const providerId = findProviderFromModel(model) + if (!providerId) return true + return allowedProviders.includes(providerId) + } +} diff --git a/apps/sim/lib/permission-groups/operation-access.test.ts b/apps/sim/lib/permission-groups/operation-access.test.ts index 457cd0815dc..c3a032fb3a4 100644 --- a/apps/sim/lib/permission-groups/operation-access.test.ts +++ b/apps/sim/lib/permission-groups/operation-access.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { collectDeniedOperationIds, + createToolAccessGate, isOperationAllowed, type OperationGateBlock, pickDefaultOperation, @@ -128,3 +129,34 @@ describe('pickDefaultOperation', () => { ) }) }) + +describe('createToolAccessGate', () => { + it('allows everything when nothing is denied', () => { + for (const deniedTools of [undefined, null, []]) { + const gate = createToolAccessGate(deniedTools) + expect(gate('slack_canvas')).toBe(true) + } + }) + + it('reuses one gate instance for every unrestricted config', () => { + expect(createToolAccessGate([])).toBe(createToolAccessGate(undefined)) + }) + + it('denies exactly the listed tool ids', () => { + const gate = createToolAccessGate(['slack_canvas']) + expect(gate('slack_canvas')).toBe(false) + expect(gate('slack_message')).toBe(true) + }) + + it('matches tool ids verbatim, so a version suffix is a different tool', () => { + const gate = createToolAccessGate(['gmail_read']) + expect(gate('gmail_read')).toBe(false) + expect(gate('gmail_read_v2')).toBe(true) + }) + + it('composes with isOperationAllowed to gate a block operation', () => { + const gate = createToolAccessGate(['slack_canvas']) + expect(isOperationAllowed(selectorBlock, 'canvas', gate)).toBe(false) + expect(isOperationAllowed(selectorBlock, 'send', gate)).toBe(true) + }) +}) diff --git a/apps/sim/lib/permission-groups/operation-access.ts b/apps/sim/lib/permission-groups/operation-access.ts index 6856cb06976..3cfeb937c71 100644 --- a/apps/sim/lib/permission-groups/operation-access.ts +++ b/apps/sim/lib/permission-groups/operation-access.ts @@ -18,6 +18,28 @@ export const NO_DENIED_OPERATIONS: ReadonlySet = new Set() /** The slice of a block config the operation gate reads. */ export type OperationGateBlock = Pick +/** Shared empty result, so a caller that finds no selector allocates nothing. */ +const NO_OPERATION_OPTIONS: readonly string[] = [] + +/** + * The operation ids a block offers, read off its `operation` dropdown. + * + * Empty when the block declares no operation selector — it runs whatever its + * `tools.access` resolves to, and there is no option list to gate. Requiring + * `type === 'dropdown'` keeps this to genuine selectors: other subblocks carry + * an `options` array that has nothing to do with operations. + */ +export function getOperationOptionIds( + block: Pick | null | undefined +): readonly string[] { + const selector = block?.subBlocks?.find( + (sb) => sb.id === OPERATION_SUBBLOCK_ID && sb.type === 'dropdown' && Array.isArray(sb.options) + ) + const options = selector?.options + if (!Array.isArray(options) || options.length === 0) return NO_OPERATION_OPTIONS + return options.map((option) => option.id) +} + /** Decides whether the caller's permission group allows a concrete tool id. */ export type IsToolAllowed = (toolId: string) => boolean @@ -125,3 +147,25 @@ export function pickDefaultOperation( return undefined } + +/** Shared allow-everything gate, so the unrestricted case allocates nothing. */ +const ALLOW_ALL_TOOLS: IsToolAllowed = () => true + +/** + * The `deniedTools` gate for a resolved permission-group config. + * + * The one place a denylist becomes a decision, shared by the client hook and + * every server path, so the two can never disagree about what an id means: the + * denylist holds block `tools.access` ids verbatim (version suffix included), + * which is exactly what {@link isOperationAllowed} resolves an operation to. + * + * Returns the shared allow-all gate when nothing is denied — the overwhelmingly + * common case — so callers can build one per block without allocating a Set. + */ +export function createToolAccessGate( + deniedTools: readonly string[] | null | undefined +): IsToolAllowed { + if (!deniedTools || deniedTools.length === 0) return ALLOW_ALL_TOOLS + const denied = new Set(deniedTools) + return (toolId: string) => !denied.has(toolId) +} diff --git a/apps/sim/lib/workflows/editing/builders.ts b/apps/sim/lib/workflows/editing/builders.ts index 28fd029958a..534333e938a 100644 --- a/apps/sim/lib/workflows/editing/builders.ts +++ b/apps/sim/lib/workflows/editing/builders.ts @@ -7,6 +7,13 @@ import { normalizeBlockRetryWaitMs, } from '@sim/workflow-types/workflow' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { createModelAccessGate } from '@/lib/permission-groups/model-access' +import { + createToolAccessGate, + isOperationAllowed, + MODEL_SUBBLOCK_ID, + OPERATION_SUBBLOCK_ID, +} from '@/lib/permission-groups/operation-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility' @@ -173,11 +180,22 @@ export function createBlockFromParams( // Add validated inputs as subBlocks if (validatedInputs) { + const isInputAllowed = createSubBlockInputGate({ + blockType: params.type, + permissionConfig, + blockId, + operationType: 'add', + skippedItems: skippedItems ?? [], + }) Object.entries(validatedInputs).forEach(([key, value]) => { if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { return } + if (!isInputAllowed(key, value)) { + return + } + let sanitizedValue = normalizeSubblockValue(key, value) sanitizedValue = normalizeConditionRouterIds(blockId, key, sanitizedValue) @@ -691,12 +709,21 @@ export function addConnectionsAsEdges( }) } -export function applyTriggerConfigToBlockSubblocks(block: any, triggerConfig: Record) { +export function applyTriggerConfigToBlockSubblocks( + block: any, + triggerConfig: Record, + isInputAllowed: SubBlockInputGate = ALLOW_ALL_INPUTS +) { if (!block?.subBlocks || !triggerConfig || typeof triggerConfig !== 'object') { return } Object.entries(triggerConfig).forEach(([configKey, configValue]) => { + /* `triggerConfig` is a runtime id the validated write path rejects, so its + keys reach sibling subBlocks only through here — redistributing an + aggregate persisted before the group's denylist changed. Same gate, so a + denied operation cannot be re-applied by the redistribution. */ + if (!isInputAllowed(configKey, configValue)) return const existingSubblock = block.subBlocks[configKey] if (existingSubblock) { const existingValue = existingSubblock.value @@ -760,6 +787,7 @@ export function filterDisallowedTools( if (!permissionConfig) return deploymentAvailableTools + const isToolAllowed = createToolAccessGate(permissionConfig.deniedTools) const allowedTools: any[] = [] for (const tool of deploymentAvailableTools) { if (tool.type === 'custom-tool' && permissionConfig.disableCustomTools) { @@ -782,12 +810,99 @@ export function filterDisallowedTools( }) continue } + /* An integration tool entry names a block and (when the block exposes more + than one) the operation to run, which is what the group's `deniedTools` + denylist is written against. Passing `''` for an absent operation is the + single-tool case, where the resolver returns the block's only tool + without consulting it. */ + if ( + typeof tool?.type === 'string' && + !isOperationAllowed(getBlock(tool.type), tool.operation ?? '', isToolAllowed) + ) { + logSkippedItem(skippedItems, { + type: 'tool_not_allowed', + operationType: 'add', + blockId, + reason: `Tool "${tool.type}${tool.operation ? `.${tool.operation}` : ''}" is blocked by access control - tool not added`, + details: { toolType: tool.type, operation: tool.operation }, + }) + continue + } allowedTools.push(tool) } return allowedTools } +/** Decides whether one subBlock input may be written. Records its own skips. */ +export type SubBlockInputGate = (key: string, value: unknown) => boolean + +/** Shared allow-everything gate, so the unrestricted case allocates nothing. */ +const ALLOW_ALL_INPUTS: SubBlockInputGate = () => true + +export interface SubBlockInputGateContext { + blockType: string + permissionConfig: PermissionGroupConfig | null | undefined + blockId: string + operationType: string + skippedItems: SkippedItem[] +} + +/** + * Builds the permission gate for one block's subBlock writes. + * + * Two fields are gated, because they are the two that name something the group + * has a policy about: `operation` decides which concrete tool id the block runs, + * and `model` names a model id. Every other input passes through untouched. + * + * A denied value is dropped from the write and reported rather than failing the + * whole operation, matching {@link filterDisallowedTools}: the block still + * lands, and the model reads the skip reason and picks a value it may use. An + * edit therefore leaves whatever the block already had, never clearing a value + * the caller is allowed to keep. + * + * Built once per block rather than per input so the denylist is indexed once, + * and so every path that writes subBlocks — including the trigger-config + * fan-out, which never passes through input validation — can share one gate. + */ +export function createSubBlockInputGate(context: SubBlockInputGateContext): SubBlockInputGate { + const { blockType, permissionConfig, blockId, operationType, skippedItems } = context + if (!permissionConfig) return ALLOW_ALL_INPUTS + + const isToolAllowed = createToolAccessGate(permissionConfig.deniedTools) + const isModelUsable = createModelAccessGate(permissionConfig) + + return (key: string, value: unknown) => { + if (typeof value !== 'string') return true + + if (key === OPERATION_SUBBLOCK_ID) { + if (isOperationAllowed(getBlock(blockType), value, isToolAllowed)) return true + logSkippedItem(skippedItems, { + type: 'tool_not_allowed', + operationType, + blockId, + reason: `Operation "${value}" on block type "${blockType}" is blocked by access control - operation not set`, + details: { blockType, operation: value }, + }) + return false + } + + if (key === MODEL_SUBBLOCK_ID) { + if (isModelUsable(value)) return true + logSkippedItem(skippedItems, { + type: 'model_not_allowed', + operationType, + blockId, + reason: `Model "${value}" is blocked by access control - model not set`, + details: { blockType, model: value }, + }) + return false + } + + return true + } +} + /** * Normalizes block IDs in operations to ensure they are valid UUIDs. * The LLM may generate human-readable IDs like "web_search" or "research_agent" diff --git a/apps/sim/lib/workflows/editing/operations.test.ts b/apps/sim/lib/workflows/editing/operations.test.ts index 7d0c21e3fd2..0303b564e32 100644 --- a/apps/sim/lib/workflows/editing/operations.test.ts +++ b/apps/sim/lib/workflows/editing/operations.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' import { applyOperationsToWorkflowState } from './engine' vi.mock('@/blocks/registry', () => { @@ -28,6 +29,29 @@ vi.mock('@/blocks/registry', () => { { id: 'language', type: 'dropdown' }, ], }, + slack: { + type: 'slack', + name: 'Slack', + tools: { + access: ['slack_message', 'slack_canvas'], + config: { + tool: ({ operation }: { operation?: string }) => + operation === 'canvas' ? 'slack_canvas' : 'slack_message', + }, + }, + subBlocks: [ + { + id: 'operation', + type: 'dropdown', + options: [ + { label: 'Send Message', id: 'send' }, + { label: 'Create Canvas', id: 'canvas' }, + ], + }, + { id: 'channel', type: 'short-input' }, + { id: 'triggerConfig', type: 'trigger-config' }, + ], + }, jira: { type: 'jira', name: 'Jira', @@ -781,3 +805,277 @@ describe('minted block ids', () => { expect(state.blocks[uuid]).toBeDefined() }) }) + +describe('permission-group tool access', () => { + const denyCanvas = { ...DEFAULT_PERMISSION_GROUP_CONFIG, deniedTools: ['slack_canvas'] } + + function emptyWorkflow() { + return { blocks: {}, edges: [], loops: {}, parallels: {} } + } + + it('drops an operation whose tool the group denies, keeping the block', () => { + const { state, skippedItems } = applyOperationsToWorkflowState( + emptyWorkflow(), + [ + { + operation_type: 'add', + block_id: '11111111-1111-4111-8111-111111111111', + params: { + type: 'slack', + name: 'Slack 1', + inputs: { operation: 'canvas', channel: '#general' }, + }, + }, + ], + denyCanvas + ) + + const block = state.blocks['11111111-1111-4111-8111-111111111111'] + expect(block).toBeDefined() + expect(block.subBlocks.operation.value).toBeNull() + expect(block.subBlocks.channel.value).toBe('#general') + expect(skippedItems).toContainEqual( + expect.objectContaining({ + type: 'tool_not_allowed', + operationType: 'add', + details: { blockType: 'slack', operation: 'canvas' }, + }) + ) + }) + + it('keeps an operation the group allows', () => { + const { state, skippedItems } = applyOperationsToWorkflowState( + emptyWorkflow(), + [ + { + operation_type: 'add', + block_id: '22222222-2222-4222-8222-222222222222', + params: { type: 'slack', name: 'Slack 1', inputs: { operation: 'send' } }, + }, + ], + denyCanvas + ) + + expect(state.blocks['22222222-2222-4222-8222-222222222222'].subBlocks.operation.value).toBe( + 'send' + ) + expect(skippedItems).toEqual([]) + }) + + it('leaves an existing operation untouched when an edit names a denied one', () => { + const blockId = '33333333-3333-4333-8333-333333333333' + const workflow = { + blocks: { + [blockId]: { + id: blockId, + type: 'slack', + name: 'Slack 1', + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: { operation: { id: 'operation', type: 'dropdown', value: 'send' } }, + outputs: {}, + data: {}, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } + + const { state, skippedItems } = applyOperationsToWorkflowState( + workflow, + [ + { + operation_type: 'edit', + block_id: blockId, + params: { inputs: { operation: 'canvas' } }, + }, + ], + denyCanvas + ) + + expect(state.blocks[blockId].subBlocks.operation.value).toBe('send') + expect(skippedItems).toContainEqual( + expect.objectContaining({ type: 'tool_not_allowed', operationType: 'edit' }) + ) + }) + + it('applies no operation gate when the group denies nothing', () => { + const { state, skippedItems } = applyOperationsToWorkflowState( + emptyWorkflow(), + [ + { + operation_type: 'add', + block_id: '44444444-4444-4444-8444-444444444444', + params: { type: 'slack', name: 'Slack 1', inputs: { operation: 'canvas' } }, + }, + ], + DEFAULT_PERMISSION_GROUP_CONFIG + ) + + expect(state.blocks['44444444-4444-4444-8444-444444444444'].subBlocks.operation.value).toBe( + 'canvas' + ) + expect(skippedItems).toEqual([]) + }) + + it('drops a model the group denies, keeping the block', () => { + const blockId = '66666666-6666-4666-8666-666666666666' + const { state, skippedItems } = applyOperationsToWorkflowState( + emptyWorkflow(), + [ + { + operation_type: 'add', + block_id: blockId, + params: { + type: 'agent', + name: 'Agent 1', + inputs: { model: 'gpt-4o', systemPrompt: 'You are helpful' }, + }, + }, + ], + { ...DEFAULT_PERMISSION_GROUP_CONFIG, deniedModels: ['GPT-4o'] } + ) + + expect(state.blocks[blockId].subBlocks.model.value).toBeNull() + expect(state.blocks[blockId].subBlocks.systemPrompt.value).toBe('You are helpful') + expect(skippedItems).toContainEqual( + expect.objectContaining({ + type: 'model_not_allowed', + details: { blockType: 'agent', model: 'gpt-4o' }, + }) + ) + }) + + it('leaves an existing model untouched when an edit names a denied one', () => { + const blockId = '77777777-7777-4777-8777-777777777777' + const workflow = { + blocks: { + [blockId]: { + id: blockId, + type: 'agent', + name: 'Agent 1', + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: { model: { id: 'model', type: 'combobox', value: 'claude-sonnet-4-5' } }, + outputs: {}, + data: {}, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } + + const { state } = applyOperationsToWorkflowState( + workflow, + [{ operation_type: 'edit', block_id: blockId, params: { inputs: { model: 'gpt-4o' } } }], + { ...DEFAULT_PERMISSION_GROUP_CONFIG, deniedModels: ['gpt-4o'] } + ) + + expect(state.blocks[blockId].subBlocks.model.value).toBe('claude-sonnet-4-5') + }) + + it('keeps a model the group allows', () => { + const blockId = '88888888-8888-4888-8888-888888888888' + const { state, skippedItems } = applyOperationsToWorkflowState( + emptyWorkflow(), + [ + { + operation_type: 'add', + block_id: blockId, + params: { type: 'agent', name: 'Agent 1', inputs: { model: 'gpt-4o' } }, + }, + ], + { ...DEFAULT_PERMISSION_GROUP_CONFIG, deniedModels: ['some-other-model'] } + ) + + expect(state.blocks[blockId].subBlocks.model.value).toBe('gpt-4o') + expect(skippedItems).toEqual([]) + }) + + it('gates the trigger-config fan-out, which no input validation covers', () => { + const blockId = '99999999-9999-4999-8999-999999999999' + const workflow = { + blocks: { + [blockId]: { + id: blockId, + type: 'slack', + name: 'Slack 1', + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'send' }, + channel: { id: 'channel', type: 'short-input', value: '#general' }, + /* The persisted aggregate, from before the tool was denied. The + fan-out redistributes THIS onto sibling subBlocks; `inputs` + cannot supply it, because `triggerConfig` is a runtime id the + validated write path rejects outright. */ + triggerConfig: { + id: 'triggerConfig', + type: 'trigger-config', + value: { operation: 'canvas', channel: '#random' }, + }, + }, + outputs: {}, + data: {}, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } + + const { state, skippedItems } = applyOperationsToWorkflowState( + workflow, + [ + { + operation_type: 'edit', + block_id: blockId, + params: { inputs: { triggerConfig: {} } }, + }, + ], + denyCanvas + ) + + const block = state.blocks[blockId] + expect(block.subBlocks.operation.value).toBe('send') + expect(block.subBlocks.channel.value).toBe('#random') + expect(skippedItems).toContainEqual( + expect.objectContaining({ type: 'tool_not_allowed', operationType: 'edit' }) + ) + }) + + it('drops an agent tool entry whose operation the group denies', () => { + const blockId = '55555555-5555-4555-8555-555555555555' + const { state, skippedItems } = applyOperationsToWorkflowState( + emptyWorkflow(), + [ + { + operation_type: 'add', + block_id: blockId, + params: { + type: 'agent', + name: 'Agent 1', + inputs: { + tools: [ + { type: 'slack', operation: 'canvas', title: 'Create Canvas' }, + { type: 'slack', operation: 'send', title: 'Send Message' }, + ], + }, + }, + }, + ], + denyCanvas + ) + + const tools = state.blocks[blockId].subBlocks.tools.value + expect(tools.map((tool: { operation: string }) => tool.operation)).toEqual(['send']) + expect(skippedItems).toContainEqual( + expect.objectContaining({ + type: 'tool_not_allowed', + details: { toolType: 'slack', operation: 'canvas' }, + }) + ) + }) +}) diff --git a/apps/sim/lib/workflows/editing/operations.ts b/apps/sim/lib/workflows/editing/operations.ts index b5e5fe20eca..eb677e3683d 100644 --- a/apps/sim/lib/workflows/editing/operations.ts +++ b/apps/sim/lib/workflows/editing/operations.ts @@ -12,6 +12,7 @@ import { applyBlockRetry, applyTriggerConfigToBlockSubblocks, createBlockFromParams, + createSubBlockInputGate, filterDisallowedTools, normalizeConditionRouterIds, normalizeResponseFormat, @@ -210,8 +211,16 @@ function mergeNestedNodesForParent( ) validationErrors.push(...childValidation.errors) + const isInputAllowed = createSubBlockInputGate({ + blockType: existingBlock.type, + permissionConfig, + blockId: existingId, + operationType: 'edit', + skippedItems, + }) Object.entries(childValidation.validInputs).forEach(([key, value]) => { if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) return + if (!isInputAllowed(key, value)) return let sanitizedValue = normalizeSubblockValue(key, value) sanitizedValue = normalizeConditionRouterIds(existingId, key, sanitizedValue) if (key === 'tools' && Array.isArray(value)) { @@ -442,6 +451,14 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon const validationResult = validateInputsForBlock(block.type, params.inputs, block_id) validationErrors.push(...validationResult.errors) + const isInputAllowed = createSubBlockInputGate({ + blockType: block.type, + permissionConfig, + blockId: block_id, + operationType: 'edit', + skippedItems, + }) + Object.entries(validationResult.validInputs).forEach(([inputKey, value]) => { // Normalize common field name variations (LLM may use plural/singular inconsistently) let key = inputKey @@ -452,6 +469,11 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { return } + + if (!isInputAllowed(key, value)) { + return + } + explicitInputKeys.add(key) let sanitizedValue = normalizeSubblockValue(key, value) @@ -492,7 +514,7 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon block.subBlocks.triggerConfig && isRecordLike(block.subBlocks.triggerConfig.value) ) { - applyTriggerConfigToBlockSubblocks(block, block.subBlocks.triggerConfig.value) + applyTriggerConfigToBlockSubblocks(block, block.subBlocks.triggerConfig.value, isInputAllowed) for (const key of Object.keys(block.subBlocks.triggerConfig.value)) { explicitInputKeys.add(key) } @@ -939,12 +961,23 @@ export function handleInsertIntoSubflowOperation( const validationResult = validateInputsForBlock(existingBlock.type, params.inputs, block_id) validationErrors.push(...validationResult.errors) + const isInputAllowed = createSubBlockInputGate({ + blockType: existingBlock.type, + permissionConfig, + blockId: block_id, + operationType: 'insert_into_subflow', + skippedItems, + }) Object.entries(validationResult.validInputs).forEach(([key, value]) => { // Skip runtime subblock IDs (webhookId, triggerPath) if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { return } + if (!isInputAllowed(key, value)) { + return + } + let sanitizedValue = normalizeSubblockValue(key, value) sanitizedValue = normalizeConditionRouterIds(block_id, key, sanitizedValue) diff --git a/apps/sim/lib/workflows/editing/types.ts b/apps/sim/lib/workflows/editing/types.ts index 32a471becb2..c2350f009f2 100644 --- a/apps/sim/lib/workflows/editing/types.ts +++ b/apps/sim/lib/workflows/editing/types.ts @@ -38,6 +38,7 @@ export const WORKFLOW_SKIPPED_ITEM_TYPES = [ 'block_not_found', 'invalid_block_type', 'block_not_allowed', + 'model_not_allowed', 'block_locked', 'tool_not_allowed', 'invalid_edge_target', diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 909453c8e16..67964b4a089 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -388,6 +388,7 @@ type ApplyWorkflowOperationsResponseRef0 = { | 'block_not_found' | 'invalid_block_type' | 'block_not_allowed' + | 'model_not_allowed' | 'block_locked' | 'tool_not_allowed' | 'invalid_edge_target' From 0d3430247506954529ebd8410bdfa95519d3374b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 25 Aug 2026 14:27:50 -0700 Subject: [PATCH 2/4] fix(access-control): use the path alias for the permission-groups type import --- apps/sim/lib/permission-groups/model-access.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/permission-groups/model-access.ts b/apps/sim/lib/permission-groups/model-access.ts index cff9a1a76dc..b677f96f15b 100644 --- a/apps/sim/lib/permission-groups/model-access.ts +++ b/apps/sim/lib/permission-groups/model-access.ts @@ -1,5 +1,5 @@ +import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { findProviderFromModel } from '@/providers/utils' -import type { PermissionGroupConfig } from './types' /** Decides whether the caller's permission group allows a concrete model id. */ export type IsModelUsable = (model: string) => boolean From 6a03859523546910cc368c70915c24b7bfb46cb6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 25 Aug 2026 15:01:54 -0700 Subject: [PATCH 3/4] fix(access-control): close two denied-tool leaks in copilot discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VFS stamped every integration schema from the shared static map before the per-viewer loop re-authored the permitted subset, so a denied operation's schema stayed published. Skip the shared copy for integration paths; the viewer loop is the only projection that knows the denylist. Block metadata resolved denied operations from the catalog's `operation.toolId`, which the projection fills only from `tools.config.tool` — a block whose operation ids are its tool ids left it undefined and read as fully permitted. Resolve through the shared operation gate instead. --- .../integration-tools-invariants.test.ts | 29 ++++++++++++ .../blocks/get-blocks-metadata-tool.test.ts | 44 +++++++++++++++++++ .../server/blocks/get-blocks-metadata-tool.ts | 14 +++--- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 8 ++++ 4 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 apps/sim/lib/copilot/integration-tools-invariants.test.ts diff --git a/apps/sim/lib/copilot/integration-tools-invariants.test.ts b/apps/sim/lib/copilot/integration-tools-invariants.test.ts new file mode 100644 index 00000000000..6eb1c206d0d --- /dev/null +++ b/apps/sim/lib/copilot/integration-tools-invariants.test.ts @@ -0,0 +1,29 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools' +import { BLOCK_REGISTRY } from '@/blocks/registry-maps' + +/** + * Sweeps the real registry for the invariant the permission gate depends on. + * + * A permission group's `deniedTools` holds the ids an admin sees in the access + * control grid, which are exactly the owning block's `tools.access` entries. + * The gate compares ids verbatim, so if an exposed tool were ever published + * under an id its block does not declare — a `_v2` superseding a still-declared + * v1, say — denying the declared id would leave the exposed one advertised and + * callable. Pin it here so that authoring mistake fails at CI rather than + * silently widening what a governed workspace can reach. + */ +describe('exposed integration tool invariants', () => { + it('publishes every tool under an id its owning block declares', () => { + const drift = getExposedIntegrationTools() + .filter( + (tool) => !(BLOCK_REGISTRY[tool.blockType]?.tools?.access ?? []).includes(tool.toolId) + ) + .map((tool) => `${tool.blockType} publishes ${tool.toolId}, which it does not declare`) + + expect(drift).toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts index cefbe354c40..ef9572fe4cc 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts @@ -153,6 +153,50 @@ describe('get blocks metadata', () => { expect(Object.keys(slack.operations).sort()).toEqual(['canvas', 'send']) }) + /** + * A block whose operation ids ARE its tool ids, declaring no + * `tools.config.tool`. The catalog projection cannot fill `operation.toolId` + * for it, so gating on that field alone would publish every denied operation. + */ + const selectorlessBlock = { + type: 'sqs', + name: 'SQS', + description: 'Queue.', + category: 'tools', + bgColor: '#000000', + icon: () => null, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Send', id: 'sqs_send' }, + { label: 'Receive', id: 'sqs_receive' }, + ], + }, + ], + tools: { access: ['sqs_send', 'sqs_receive'] }, + inputs: {}, + outputs: {}, + } as unknown as BlockConfig + + it('withholds a denied operation on a block that declares no tool selector', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + allowedIntegrations: ['sqs'], + deniedTools: ['sqs_receive'], + }) + vi.mocked(getBlock).mockReturnValue(selectorlessBlock) + + const result = await getBlocksMetadataServerTool.execute( + { blockIds: ['sqs'] }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + const sqs = result.metadata.sqs as { operations: Record } + expect(Object.keys(sqs.operations)).toEqual(['sqs_send']) + }) + it('withholds a block whose every operation the group denies', async () => { mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'], diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 9c937fad0fe..5cdd43af72a 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -20,9 +20,11 @@ import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { + collectDeniedOperationIds, createToolAccessGate, type IsToolAllowed, OPERATION_SUBBLOCK_ID, + type OperationGateBlock, } from '@/lib/permission-groups/operation-access' import { getBlock } from '@/blocks/registry' import { AuthMode, type BlockConfig, type SubBlockConfig } from '@/blocks/types' @@ -134,13 +136,15 @@ function toCopilotBlockMetadata(detail: CatalogBlockDetail): CopilotBlockMetadat */ function withDeniedToolsRemoved( metadata: CopilotBlockMetadata, + block: OperationGateBlock, isToolAllowed: IsToolAllowed ): CopilotBlockMetadata | null { const operations = metadata.operations ?? {} - const deniedOperations = new Set() - for (const [operationId, operation] of Object.entries(operations)) { - if (operation.toolId && !isToolAllowed(operation.toolId)) deniedOperations.add(operationId) - } + /* Resolved through the shared operation gate rather than `operation.toolId`: + the catalog projection fills that field only from `tools.config.tool`, so a + block whose operation ids ARE its tool ids leaves it undefined and every one + of its operations would read as permitted. */ + const deniedOperations = collectDeniedOperationIds(block, Object.keys(operations), isToolAllowed) const tools = metadata.tools.filter((tool) => isToolAllowed(tool.id)) if (deniedOperations.size === 0 && tools.length === metadata.tools.length) return metadata @@ -278,7 +282,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< continue } - const permitted = withDeniedToolsRemoved(metadata, isToolAllowed) + const permitted = withDeniedToolsRemoved(metadata, blockConfig, isToolAllowed) if (!permitted) { logger.debug('Block has no operation this permission group allows', { blockId }) continue diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index ccaa1239a73..6f562aa93cf 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -455,6 +455,7 @@ function getStaticToolConfigs(): ReadonlyMap { } const BLOCK_SCHEMA_PATH_PREFIX = 'components/blocks/' +const INTEGRATION_SCHEMA_PATH_PREFIX = 'components/integrations/' /** The per-viewer projections applied to a shared static component file. */ interface StaticFileProjection { @@ -1049,6 +1050,13 @@ export class WorkspaceVFS { isToolAllowed, } for (const [path, content] of getStaticComponentFiles()) { + /* Integration schemas are authored per viewer from + `viewerIntegrationTools` immediately below, which is the only + projection that knows the group's per-tool denylist. Stamping + the shared copy first would publish a denied operation's schema + that the loop below never overwrites, because it only writes the + operations the viewer may use. */ + if (path.startsWith(INTEGRATION_SCHEMA_PATH_PREFIX)) continue if (isStaticFileHidden(path, blockVisibility, staticFileGate)) continue this.files.set(path, projectStaticComponentFile(path, content, staticFileProjection)) } From 94383393e0115504d4dce0482e90343e5ea7365e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 25 Aug 2026 15:10:31 -0700 Subject: [PATCH 4/4] fix(access-control): key the copilot schema cache on permission policy The deferred integration-tool schemas now depend on the viewer's permission group, but the cache key encoded only identity and block visibility, so an admin's change to deniedTools took effect only when the entry expired. Resolve the config before the key and add a gate signature alongside the existing visibility signature, mirroring how block visibility already keys the same cache. The read moves out of the cached section rather than being added: what the entry caches is a user-tool schema per exposed integration tool, which dominates it. --- apps/sim/lib/copilot/chat/payload.test.ts | 28 +++++++++++++++++ apps/sim/lib/copilot/chat/payload.ts | 30 ++++++++++++------- .../copilot/integration-tool-projection.ts | 16 ++++++++++ 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index af3f1742803..9b6274051ec 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -299,6 +299,34 @@ describe('buildIntegrationToolSchemas', () => { expect(second[0].input_schema).not.toHaveProperty('mutated') expect(second[0].outputs).not.toHaveProperty('mutated') }) + + it('rebuilds instead of serving a cache entry from the previous policy', async () => { + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' }) + mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: null, deniedTools: [] }) + + const before = await buildIntegrationToolSchemas( + 'user-policy', + undefined, + { schemaSurface: 'copilot' }, + 'workspace-policy' + ) + expect(before.map((tool) => tool.name)).toContain('gmail_send') + + // An admin denies the tool. The viewer and surface are unchanged, so only + // the policy component of the key can force a rebuild. + mockGetUserPermissionConfig.mockResolvedValue({ + allowedIntegrations: null, + deniedTools: ['gmail_send'], + }) + + const after = await buildIntegrationToolSchemas( + 'user-policy', + undefined, + { schemaSurface: 'copilot' }, + 'workspace-policy' + ) + expect(after.map((tool) => tool.name)).not.toContain('gmail_send') + }) }) describe('buildCopilotRequestPayload', () => { diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 4f087776a51..bf888de5a7b 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -9,6 +9,7 @@ import { getBlockVisibilityForCopilot, visibilitySignature } from '@/lib/copilot import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1' import { type IntegrationGateConfig, + integrationGateSignature, projectIntegrationToolsForViewer, } from '@/lib/copilot/integration-tool-projection' import { buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' @@ -110,11 +111,14 @@ function getIntegrationToolSchemaCacheKey( userId: string, workspaceId: string | undefined, schemaSurface: string, - visSignature: string + visSignature: string, + gateSignature: string ): string { // The visibility signature keys the entry to the viewer's gated projection — // two users in one workspace with different preview reveals must not share. - return JSON.stringify([userId, workspaceId ?? null, schemaSurface, visSignature]) + // The gate signature does the same for permission-group policy, so an admin's + // change takes effect on the next build rather than when the entry expires. + return JSON.stringify([userId, workspaceId ?? null, schemaSurface, visSignature, gateSignature]) } function cloneToolSchemas(toolSchemas: ToolSchema[]): ToolSchema[] { @@ -151,11 +155,20 @@ export async function buildIntegrationToolSchemas( ): Promise { const schemaSurface = options.schemaSurface ?? 'copilot' const vis = await getBlockVisibilityForCopilot(userId, workspaceId) + // Resolved before the key, not inside the cached build, so the entry is keyed + // to the policy it was produced under. The read this adds is cheap next to + // what the entry caches: a user-tool schema per exposed integration tool. + let permissionConfig: IntegrationGateConfig | null = null + if (workspaceId) { + const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check') + permissionConfig = await getUserPermissionConfig(userId, workspaceId) + } const cacheKey = getIntegrationToolSchemaCacheKey( userId, workspaceId, schemaSurface, - visibilitySignature(vis) + visibilitySignature(vis), + integrationGateSignature(permissionConfig) ) const cached = integrationToolSchemaCache.get(cacheKey) if (cached) { @@ -167,7 +180,8 @@ export async function buildIntegrationToolSchemas( messageId, { schemaSurface }, workspaceId, - vis + vis, + permissionConfig ).catch((error) => { integrationToolSchemaCache.delete(cacheKey) throw error @@ -185,15 +199,11 @@ async function buildIntegrationToolSchemasUncached( messageId: string | undefined, options: Required, workspaceId?: string, - vis: BlockVisibilityState | null = null + vis: BlockVisibilityState | null = null, + permissionConfig: IntegrationGateConfig | null = null ): Promise { const reqLogger = logger.withMetadata({ messageId }) const integrationTools: ToolSchema[] = [] - let permissionConfig: IntegrationGateConfig | null = null - if (workspaceId) { - const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check') - permissionConfig = await getUserPermissionConfig(userId, workspaceId) - } try { const { createUserToolSchema } = await import('@/tools/params') diff --git a/apps/sim/lib/copilot/integration-tool-projection.ts b/apps/sim/lib/copilot/integration-tool-projection.ts index ccf89005fe7..ba96dead1de 100644 --- a/apps/sim/lib/copilot/integration-tool-projection.ts +++ b/apps/sim/lib/copilot/integration-tool-projection.ts @@ -74,6 +74,22 @@ export function projectIntegrationToolsForViewer( return { tools, allowedBlockTypes, isToolAllowed } } +/** + * Stable signature of the policy {@link projectIntegrationToolsForViewer} reads, + * for keying caches whose contents depend on the projection. + * + * The projection is only as fresh as what keys it: an entry cached under a + * viewer's identity alone outlives the policy that produced it, so an admin's + * change would not take effect until the entry expired. Mirrors + * `visibilitySignature`, which does the same job for block visibility. + */ +export function integrationGateSignature(config: IntegrationGateConfig | null | undefined): string { + return JSON.stringify([ + config?.allowedIntegrations ? [...config.allowedIntegrations].sort() : null, + config?.deniedTools?.length ? [...config.deniedTools].sort() : null, + ]) +} + /** What a viewer's `deniedTools` denylist costs the block schemas they are shown. */ export interface DeniedBlockOperations { /**