mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
improvement(access-control): wire tool and model permissions into copilot editing (#7080)
* improvement(access-control): wire tool and model permissions into copilot editing 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. * fix(access-control): use the path alias for the permission-groups type import * fix(access-control): close two denied-tool leaks in copilot discovery 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. * 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.
This commit is contained in:
@@ -5742,6 +5742,7 @@
|
||||
"block_not_found",
|
||||
"invalid_block_type",
|
||||
"block_not_allowed",
|
||||
"model_not_allowed",
|
||||
"block_locked",
|
||||
"tool_not_allowed",
|
||||
"invalid_edge_target",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 <T extends { type: string }>(blocks: T[]): T[] => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<ToolSchema[]> {
|
||||
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<BuildIntegrationToolSchemasOptions>,
|
||||
workspaceId?: string,
|
||||
vis: BlockVisibilityState | null = null
|
||||
vis: BlockVisibilityState | null = null,
|
||||
permissionConfig: IntegrationGateConfig | null = null
|
||||
): Promise<ToolSchema[]> {
|
||||
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, {
|
||||
|
||||
@@ -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<typeof projectIntegrationToolsForViewer>[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)
|
||||
})
|
||||
})
|
||||
@@ -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<string> | 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<string, ReadonlySet<string>>
|
||||
/** Block types with nothing left to configure, withheld entirely. */
|
||||
fullyDenied: ReadonlySet<string>
|
||||
}
|
||||
|
||||
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<string, ReadonlySet<string>>()
|
||||
const fullyDenied = new Set<string>()
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) &&
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
|
||||
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<string, unknown> }
|
||||
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<string, unknown> }
|
||||
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'] },
|
||||
|
||||
@@ -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<typeof GetBlocksMetadataInputSchema>,
|
||||
z.infer<typeof GetBlocksMetadataResultSchema>
|
||||
@@ -138,6 +199,7 @@ export const getBlocksMetadataServerTool: BaseServerTool<
|
||||
permissionConfig?.allowedIntegrations ?? null,
|
||||
getAllowedIntegrationsFromEnv()
|
||||
)
|
||||
const isToolAllowed = createToolAccessGate(permissionConfig?.deniedTools)
|
||||
const visibility = overlayVisibility()
|
||||
|
||||
const result: Record<string, CopilotBlockMetadata> = {}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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<string, VfsToolAuth> = {}
|
||||
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,
|
||||
|
||||
@@ -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<string, Array<Pick<BlockConfig, 'type' | 'prev
|
||||
function isBlockOwnerHidden(
|
||||
owner: Pick<BlockConfig, 'type' | 'preview'>,
|
||||
vis: BlockVisibilityState | null,
|
||||
allowedIntegrationTypes: ReadonlySet<string> | 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<string> | null
|
||||
/** Block types whose every selectable operation the viewer's group denies. */
|
||||
fullyDeniedBlockTypes: ReadonlySet<string>
|
||||
}
|
||||
|
||||
const UNGATED_STATIC_FILES: StaticFileGate = {
|
||||
allowedIntegrationTypes: null,
|
||||
fullyDeniedBlockTypes: new Set(),
|
||||
}
|
||||
|
||||
function isStaticFileHidden(
|
||||
path: string,
|
||||
vis: BlockVisibilityState | null,
|
||||
allowedIntegrationTypes: ReadonlySet<string> | 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<string> | 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<string, ToolConfig> | null = null
|
||||
|
||||
function getStaticToolConfigs(): ReadonlyMap<string, ToolConfig> {
|
||||
if (staticToolConfigs) return staticToolConfigs
|
||||
const configs = new Map<string, ToolConfig>()
|
||||
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<string, string> {
|
||||
const allBlocks = Object.values(BLOCK_REGISTRY)
|
||||
const visibleBlocks = allBlocks.filter((block) => !block.hideFromToolbar)
|
||||
const exposedTools = getExposedIntegrationTools()
|
||||
const toolConfigs = new Map<string, ToolConfig>()
|
||||
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<string, string> {
|
||||
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
|
||||
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)
|
||||
const staticFileGate: StaticFileGate = {
|
||||
allowedIntegrationTypes: allowedBlockTypes,
|
||||
fullyDeniedBlockTypes: deniedOperations.fullyDenied,
|
||||
}
|
||||
const staticFileProjection: StaticFileProjection = {
|
||||
sandboxEntitled,
|
||||
deniedOperations,
|
||||
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))
|
||||
}
|
||||
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) =>
|
||||
|
||||
@@ -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<string> | null {
|
||||
return allowedIntegrations
|
||||
? new Set(allowedIntegrations.map((integration) => integration.toLowerCase()))
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<PermissionGroupConfig, 'deniedModels' | 'allowedModelProviders'>
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,28 @@ export const NO_DENIED_OPERATIONS: ReadonlySet<string> = new Set()
|
||||
/** The slice of a block config the operation gate reads. */
|
||||
export type OperationGateBlock = Pick<BlockConfig, 'tools'>
|
||||
|
||||
/** 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<BlockConfig, 'subBlocks'> | 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)
|
||||
}
|
||||
|
||||
@@ -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<string, any>) {
|
||||
export function applyTriggerConfigToBlockSubblocks(
|
||||
block: any,
|
||||
triggerConfig: Record<string, any>,
|
||||
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"
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user