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..9b6274051ec 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(() => [ { @@ -298,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 38ca4508f90..bf888de5a7b 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -8,25 +8,18 @@ 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, + integrationGateSignature, + 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' @@ -118,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[] { @@ -159,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) { @@ -175,7 +180,8 @@ export async function buildIntegrationToolSchemas( messageId, { schemaSurface }, workspaceId, - vis + vis, + permissionConfig ).catch((error) => { integrationToolSchemaCache.delete(cacheKey) throw error @@ -193,22 +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 allowedIntegrations = getAllowedIntegrationsFromEnv() - if (workspaceId) { - const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check') - const permissionConfig = await getUserPermissionConfig(userId, workspaceId) - allowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - allowedIntegrations - ) - } - const allowedIntegrationTypes = allowedIntegrations - ? new Set(allowedIntegrations.map((integration) => integration.toLowerCase())) - : null try { const { createUserToolSchema } = await import('@/tools/params') @@ -224,14 +219,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..ba96dead1de --- /dev/null +++ b/apps/sim/lib/copilot/integration-tool-projection.ts @@ -0,0 +1,156 @@ +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 } +} + +/** + * 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 { + /** + * 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-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/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..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 @@ -86,6 +86,132 @@ 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']) + }) + + /** + * 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'], + 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..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 @@ -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,13 @@ 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 { + 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' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' @@ -116,6 +124,59 @@ 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, + block: OperationGateBlock, + isToolAllowed: IsToolAllowed +): CopilotBlockMetadata | null { + const operations = metadata.operations ?? {} + /* 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 + + 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 +199,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() ) + const isToolAllowed = createToolAccessGate(permissionConfig?.deniedTools) const visibility = overlayVisibility() const result: Record = {} @@ -219,6 +281,13 @@ export const getBlocksMetadataServerTool: BaseServerTool< }) continue } + + const permitted = withDeniedToolsRemoved(metadata, blockConfig, 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..6f562aa93cf 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,66 @@ 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/' +const INTEGRATION_SCHEMA_PATH_PREFIX = 'components/integrations/' + +/** 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 +517,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 +663,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 +1031,35 @@ 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) + /* 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)) } - 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 +1076,7 @@ export class WorkspaceVFS { } this.files.set( 'components/triggers/triggers.md', - buildTriggerOverview(blockVisibility, allowedIntegrationTypes) + buildTriggerOverview(blockVisibility, staticFileGate) ) span.setAttributes({ @@ -3104,14 +3181,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..b677f96f15b --- /dev/null +++ b/apps/sim/lib/permission-groups/model-access.ts @@ -0,0 +1,35 @@ +import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { findProviderFromModel } from '@/providers/utils' + +/** 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'