fix(access-control): default every operation and model picker to one the permission group allows (#6720)

* fix(access-control): default every operation picker to one the group allows

The block editor's operation dropdown already hid operations whose tool the
caller's permission group denies, but it seeded its default without waiting for
that config. `usePermissionConfig` resolves as "nothing denied" while its query
is in flight, so a freshly dropped block persisted the static first operation —
and nothing revisits a field that already holds a value, so the correction that
arrived with the config never applied. A user whose group denies `slack_message`
still got a Slack block sitting on Send Message. The model combobox already had
this guard; the dropdown did not.

Consolidates the rule behind `lib/permission-groups/operation-access` and
`useOperationAccess`, which resolves an operation to its tool without guessing
(an unresolvable one stays visible; the server gate stays authoritative) and
withholds a default until the config has loaded, so seeding on a defined value
is the whole guard.

Applies it to every surface that offers or seeds an operation:

- block editor dropdown — default now waits for the config
- agent block tool list — operations were not gated at all; the picker now hides
  denied ones, drops blocks whose every operation is denied, and defaults to the
  first allowed
- canvas search / connection picker — the tool-operation index was filtered only
  by the block allowlist, so denied operations were still offered as one-click
  block drops
- block creation — a declared default operation the group denies is replaced
  with the first allowed one, and a denied preset operation is discarded

* fix(access-control): gate the seeded model too, and collapse the seed rule

Block creation seeds `model` the same way it seeds `operation`: `agent`,
`router` and `evaluator` all declare `defaultValue: 'claude-sonnet-5'`, and
`prepareBlockState` wrote it unconditionally. The model combobox only fills a
field that is empty, so a group denying that model (or the Anthropic provider)
got an Agent block pre-filled with a model it cannot run — the same bug as the
operation one, on the other axis the permission group governs.

Rather than a second bespoke gate, `prepareBlockState` now takes one veto,
`isSeededValueAllowed(subBlockId, value)`, and seeds nothing when a declared
default is denied. Nothing substitutes a replacement there any more: the
editor's own permission-aware pickers already resolve the right one and only
fill an empty field, and substituting in the store would drift from
`getDefaultBlockName`, which names a block after its *declared* default. That
also deletes `firstAllowedOperation` and its copy of subblock-option
enumeration.

Review follow-ups:

- `usePermissionConfig` gains `isModelUsable` (denylist AND provider allowlist);
  the combobox's two hand-rolled copies of that pair now call it
- `isToolAllowed`/`isModelAllowed` index their denylists — the gate calls them
  once per option of every block offered, so a linear scan made a check's cost
  scale with denylist length (measured 3.2ms -> 0.30ms per search-index build
  at 500 denied tools)
- `useOperationAccess` had three members with three different loading
  semantics, one documented as unsafe alone; it now exposes one withholding
  `resolveOperationGate`
- the agent tool picker derived its option list twice with the empty-id filter
  on only one path; both callers now share one `{ options, denied }` result
- `OPERATION_SUBBLOCK_ID` was a verbatim copy of the private constant in
  `canvas-sentence.ts`, doc comment included; that file now imports it

* chore: trim restating comments and a dead coalesce

Cleanup-pass findings on this branch's own lines: comments that restated the
code they sat on, a four-line note whose sibling said the same in one, and a
`?? undefined` on a non-nullable value. The comment on the tool-picker filter
now explains the clause that actually needed it (an empty option list is not a
denied one) instead of narrating the filter.

Left alone as pre-existing and out of scope: the inline `staleTime` literal in
`useAllowedIntegrationsFromEnv`, and the Operation selector's raw label /
plain `Combobox` — both byte-identical to staging and matching the convention
of every sibling field in that panel.

* fix(access-control): seed nothing restricted when the config is unknown

Block creation is one-shot, so the withholding pattern the editor's pickers use
does not transfer: withholding the predicate there meant `prepareBlockState`
seeded the declared `operation`/`model` defaults unchecked, and nothing
revisits a field that already holds a value — so a block added before the
permission config resolved kept a model the group may deny.

Both restricted fields now seed empty until the config is known; the pickers
fill them the moment it resolves. A preset operation is still honoured in that
window: unlike a declared default it is the user's explicit pick, and the
server gates the run.

* fix(access-control): make the loading rule structural, not a convention

Two review bots found the same class of bug in two more places, which is the
real finding: "never persist from a predicate that reads as unrestricted while
the config loads" was a rule each callsite re-implemented, and the rule had
already been forgotten twice.

Closes both reported instances and moves the rule somewhere it cannot be
forgotten again:

- `useOperationAccess.resolveSeedGate` now owns the creation-time veto for both
  restricted fields, so `workflow.tsx` states no policy of its own — it asks for
  a gate and passes it on. Previously the model half of the invariant was
  carried by an operation-shaped object that merely happened to be absent
  during the same window.
- The agent tool picker and both operation selectors close while the config is
  unknown. Every list they offer — blocks, operations, MCP and custom tools —
  reads as unrestricted for that beat, and each pick is a one-shot write.
- A preset operation goes through the same gate as a declared default. It comes
  from the search index, which is itself unfiltered while loading, so it is not
  the informed pick it looks like.
- `isPermissionLoading` is exposed from one hook, so all four surfaces read the
  same symbol instead of four spellings of the same condition.

Also from the review passes: dropped `isModelAllowed`/`isProviderAllowed` from
the public interface (consolidating onto `isModelUsable` left them with no
external consumer), un-exported `resolveOperationToolId` (no non-test caller),
and corrected the `isSeededValueAllowed` TSDoc, which still described the
contract the previous commit replaced.

Tests: replaced a case that asserted its own fixture rather than the code with
coverage of the two guard branches that were genuinely untested — an empty-string
and a non-string declared default must bypass the gate, since both mean "nothing
was declared" rather than a value to authorize.

* fix(access-control): only gate a model field the provider allowlist is about

The seed gate ran `isModelUsable` on every subblock named `model`, but
`getProviderFromModel` resolves chat models and falls back to `ollama` for
everything else. 28 of the 44 seeded model defaults in the registry are
embedding, speech, image, video or search ids — so for any group with a
provider allowlist that omits Ollama, those blocks were created with an empty
model.

Adds `findProviderFromModel`, the non-guessing half of `getProviderFromModel`,
which returns `null` where the registry declares nothing. `isModelUsable` now
treats an unresolved id as not-a-provider-choice and leaves it alone, matching
the rule the operation gate already follows: never guess, and let the server
stay authoritative. `getProviderFromModel` delegates to it, so there is one
resolution path and its ollama fallback is unchanged.

This also repairs the same misjudgement where it predates the branch: the model
combobox filtered its options through the identical provider check, so those 28
defaults were already being hidden from their own pickers for allowlisted
groups.

The dead `try/catch` around the old call went with it — `getProviderFromModel`
returns a fallback rather than throwing for an unknown id.
This commit is contained in:
Waleed
2026-08-14 19:20:49 -07:00
committed by GitHub
parent 8fb571ac54
commit 96f7b2395a
17 changed files with 748 additions and 141 deletions
@@ -15,7 +15,6 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { getProviderFromModel } from '@/providers/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/**
@@ -108,30 +107,18 @@ export const ComboBox = memo(function ComboBox({
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
// Permission-based filtering for model dropdowns
const {
isProviderAllowed,
isModelAllowed,
isLoading: isPermissionLoading,
} = usePermissionConfig()
const { isModelUsable, isLoading: isPermissionLoading } = usePermissionConfig()
// Evaluate static options if provided as a function
const staticOptions = useMemo(() => {
const opts = typeof options === 'function' ? options() : options
if (subBlockId === 'model') {
return opts.filter((opt) => {
const modelId = typeof opt === 'string' ? opt : opt.id
if (!isModelAllowed(modelId)) return false
try {
return isProviderAllowed(getProviderFromModel(modelId))
} catch {
return true
}
})
return opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
}
return opts
}, [options, subBlockId, isProviderAllowed, isModelAllowed])
}, [options, subBlockId, isModelUsable])
const {
fetchedOptions,
@@ -210,15 +197,7 @@ export const ComboBox = memo(function ComboBox({
fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) {
opts = opts.filter((opt) => {
const modelId = typeof opt === 'string' ? opt : opt.id
if (!isModelAllowed(modelId)) return false
try {
return isProviderAllowed(getProviderFromModel(modelId))
} catch {
return true
}
})
opts = opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
}
// Merge hydrated option if not already present
@@ -251,8 +230,7 @@ export const ComboBox = memo(function ComboBox({
hydratedOption,
createdOption,
subBlockId,
isProviderAllowed,
isModelAllowed,
isModelUsable,
])
// Convert options to Combobox format
@@ -2,6 +2,10 @@ import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { ChipTag, Combobox, type ComboboxOption } from '@sim/emcn'
import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import {
NO_DENIED_OPERATIONS,
OPERATION_SUBBLOCK_ID,
} from '@/lib/permission-groups/operation-access'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
import { useFetchedOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options'
@@ -11,7 +15,7 @@ import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useOperationAccess } from '@/hooks/use-operation-access'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/** Selected-value badges shown before folding the rest into a "+N" badge. */
@@ -97,7 +101,7 @@ export const Dropdown = memo(function Dropdown({
preserveLabelCase = false,
}: DropdownProps) {
const activeSearchTarget = useActiveSearchTarget()
const { isToolAllowed } = usePermissionConfig()
const { getDeniedOperations, resolveDefaultOperation, isPermissionLoading } = useOperationAccess()
const [storeValue, setStoreValue] = useSubBlockValue<string | string[]>(blockId, subBlockId) as [
string | string[] | null | undefined,
(value: string | string[]) => void,
@@ -189,26 +193,17 @@ export const Dropdown = memo(function Dropdown({
/**
* Operation IDs whose resolved tool is denied by the caller's permission
* group. Only the `operation` selector of a block with a tool selector is
* gated. Denied operations are hidden from the picker (still resolvable for
* label display); the server is the authoritative gate regardless.
* group. Only the `operation` selector is gated. Denied operations are hidden
* from the picker (still resolvable for label display); the server is the
* authoritative gate regardless.
*/
const deniedOperationIds = useMemo(() => {
const denied = new Set<string>()
if (subBlockId !== 'operation') return denied
const selectTool = blockConfig?.tools?.config?.tool
if (!selectTool) return denied
for (const opt of allOptions) {
const optionId = typeof opt === 'string' ? opt : opt.id
try {
const toolId = selectTool({ operation: optionId })
if (toolId && !isToolAllowed(toolId)) denied.add(optionId)
} catch {
// Unresolvable from the operation alone — leave it visible; the server still enforces.
}
}
return denied
}, [subBlockId, blockConfig, allOptions, isToolAllowed])
if (subBlockId !== OPERATION_SUBBLOCK_ID) return NO_DENIED_OPERATIONS
return getDeniedOperations(
blockConfig,
allOptions.map((opt) => (typeof opt === 'string' ? opt : opt.id))
)
}, [subBlockId, blockConfig, allOptions, getDeniedOperations])
const comboboxOptions = useMemo((): ComboboxOption[] => {
const toLabel = (raw: string) => (preserveLabelCase ? raw : raw.toLowerCase())
@@ -232,17 +227,22 @@ export const Dropdown = memo(function Dropdown({
const defaultOptionValue = useMemo(() => {
if (multiSelect) return undefined
const firstSelectable = comboboxOptions.find((opt) => !opt.hidden)
if (defaultValue !== undefined) {
// Don't seed a denied operation as the default; use the first allowed option.
if (deniedOperationIds.has(defaultValue)) {
return firstSelectable?.value
}
return defaultValue
/**
* The operation field defaults through the permission gate, which withholds
* a value until the group config has loaded. Seeding the static first
* option in that window would persist an operation the group denies —
* nothing revisits a field that already holds a value, so the correction
* that arrives with the config would never apply.
*/
if (subBlockId === OPERATION_SUBBLOCK_ID) {
const selectableIds = comboboxOptions.filter((opt) => !opt.hidden).map((opt) => opt.value)
return resolveDefaultOperation(blockConfig, selectableIds, defaultValue)
}
return firstSelectable?.value
}, [defaultValue, comboboxOptions, deniedOperationIds, multiSelect])
if (defaultValue !== undefined) return defaultValue
return comboboxOptions.find((opt) => !opt.hidden)?.value
}, [defaultValue, comboboxOptions, multiSelect, subBlockId, blockConfig, resolveDefaultOperation])
useEffect(() => {
if (multiSelect || defaultOptionValue === undefined) {
@@ -437,7 +437,9 @@ export const Dropdown = memo(function Dropdown({
onChange={handleChange}
onMultiSelectChange={handleMultiSelectChange}
placeholder={placeholder}
disabled={disabled}
/* The operation list only drops denied entries once the config resolves,
and a pick here persists — matching the agent tool selector. */
disabled={disabled || (subBlockId === OPERATION_SUBBLOCK_ID && isPermissionLoading)}
editable={false}
onOpenChange={handleOpenChange}
overlayContent={multiSelectOverlay ?? singleSelectOverlay}
@@ -26,6 +26,10 @@ import {
} from '@/lib/mcp/tool-validation'
import type { McpToolSchema } from '@/lib/mcp/types'
import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth'
import {
NO_DENIED_OPERATIONS,
OPERATION_SUBBLOCK_ID,
} from '@/lib/permission-groups/operation-access'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { resolveStoredToolName } from '@/lib/workflows/subblocks/display'
import { buildToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks'
@@ -65,7 +69,7 @@ import { getAllBlocks, getBlock } from '@/blocks'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getTileIconColorClass } from '@/blocks/icon-color'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import type { BlockConfig, SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
@@ -85,6 +89,7 @@ import {
import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useOperationAccess } from '@/hooks/use-operation-access'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
@@ -355,25 +360,23 @@ function resolveCustomToolFromReference(
/**
* Checks if a block supports multiple operations.
*
* @param blockType - The block type to check
* @param block - The block config to check
* @returns `true` if the block has more than one tool operation available
*/
function hasMultipleOperations(blockType: string): boolean {
const block = getAllBlocks().find((b) => b.type === blockType)
function hasMultipleOperations(block: BlockConfig | undefined): boolean {
return (block?.tools?.access?.length || 0) > 1
}
/**
* Gets the available operation options for a multi-operation tool.
*
* @param blockType - The block type to get operations for
* @param block - The block config to get operations for
* @returns Array of operation options with label and id properties
*/
function getOperationOptions(blockType: string): { label: string; id: string }[] {
const block = getAllBlocks().find((b) => b.type === blockType)
function getOperationOptions(block: BlockConfig | undefined): { label: string; id: string }[] {
if (!block || !block.tools?.access) return []
const operationSubBlock = block.subBlocks.find((sb) => sb.id === 'operation')
const operationSubBlock = block.subBlocks.find((sb) => sb.id === OPERATION_SUBBLOCK_ID)
if (
operationSubBlock &&
operationSubBlock.type === 'dropdown' &&
@@ -662,13 +665,47 @@ export const ToolInput = memo(function ToolInput({
const provider = model ? getProviderFromModel(model) : ''
const supportsToolControl = provider ? supportsToolUsageControl(provider) : false
const { filterBlocks, config: permissionConfig } = usePermissionConfig()
const {
filterBlocks,
config: permissionConfig,
isLoading: isPermissionLoading,
} = usePermissionConfig()
const { getDeniedOperations } = useOperationAccess()
/**
* A tool block's selectable operations paired with the ones the caller's
* permission group denies.
*
* Both callers derive from this single result so they cannot drift: the
* picker *removes* denied operations (it must never offer or default to one),
* while the editor's selector *hides* them (a tool already saved on one keeps
* showing its name).
*/
const getOperationChoices = useCallback(
(block: BlockConfig | undefined) => {
const options = getOperationOptions(block).filter((option) => option.id !== '')
return {
options,
denied: getDeniedOperations(
block,
options.map((option) => option.id)
),
}
},
[getDeniedOperations]
)
const customBlockOverlayVersion = useCustomBlockOverlayVersion()
const toolBlocks = useMemo(() => {
const allToolBlocks = getAllBlocks().filter(isAgentToolBlock)
return filterBlocks(allToolBlocks)
}, [filterBlocks, customBlockOverlayVersion])
/* An empty option list means the block declares no selectable operation, so
there is nothing to gate — only a wholly denied one leaves the picker. */
return filterBlocks(allToolBlocks).filter((block) => {
if (!hasMultipleOperations(block)) return true
const { options, denied } = getOperationChoices(block)
return options.length === 0 || options.some((option) => !denied.has(option.id))
})
}, [filterBlocks, customBlockOverlayVersion, getOperationChoices])
const hasBackfilledRef = useRef(false)
useEffect(() => {
@@ -744,7 +781,7 @@ export const ToolInput = memo(function ToolInput({
* @returns `true` if tool is already selected (for single-operation tools only)
*/
const isToolAlreadySelected = (toolId: string, blockType: string) => {
if (hasMultipleOperations(blockType)) {
if (hasMultipleOperations(getBlock(blockType))) {
return false
}
// Custom blocks all share toolId `workflow_executor`, so dedup-by-toolId would
@@ -783,9 +820,10 @@ export const ToolInput = memo(function ToolInput({
(toolBlock: (typeof toolBlocks)[0]) => {
if (isPreview || disabled) return
const hasOperations = hasMultipleOperations(toolBlock.type)
const operationOptions = hasOperations ? getOperationOptions(toolBlock.type) : []
const defaultOperation = operationOptions.length > 0 ? operationOptions[0].id : undefined
const { options, denied } = hasMultipleOperations(toolBlock)
? getOperationChoices(toolBlock)
: { options: [], denied: NO_DENIED_OPERATIONS }
const defaultOperation = options.find((option) => !denied.has(option.id))?.id
const toolId = getToolIdForOperation(toolBlock.type, defaultOperation, toolBlock)
if (!toolId) return
@@ -821,7 +859,7 @@ export const ToolInput = memo(function ToolInput({
setOpen(false)
},
[isPreview, disabled, isToolAlreadySelected, selectedTools, setStoreValue]
[isPreview, disabled, isToolAlreadySelected, selectedTools, setStoreValue, getOperationChoices]
)
const handleAddCustomTool = useCallback(
@@ -1670,7 +1708,11 @@ export const ToolInput = memo(function ToolInput({
options={[]}
groups={toolGroups}
placeholder='Add tool...'
disabled={disabled}
/* Every list this picker offers — blocks, operations, MCP and custom
tools — reads as unrestricted until the permission config resolves,
and adding a tool is a one-shot write that nothing revisits. Closed
rather than optimistic for that beat. */
disabled={disabled || isPermissionLoading}
searchable
searchPlaceholder='Search tools...'
maxHeight={240}
@@ -1799,7 +1841,8 @@ export const ToolInput = memo(function ToolInput({
)
: []
const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type)
const hasOperations =
!isCustomTool && !isMcpTool && hasMultipleOperations(toolBlock ?? undefined)
const hasParams = useSubBlocks
? displaySubBlocks.length > 0
: displayParams.filter((param) => evaluateParameterCondition(param, tool)).length > 0
@@ -2059,26 +2102,33 @@ export const ToolInput = memo(function ToolInput({
<div className='flex flex-col gap-2.5 overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] p-2'>
{/* Operation dropdown for tools with multiple operations */}
{(() => {
const hasOperations = hasMultipleOperations(tool.type)
const operationOptions = hasOperations ? getOperationOptions(tool.type) : []
if (!hasOperations) return null
const { options: operationOptions, denied } = getOperationChoices(
toolBlock ?? undefined
)
if (operationOptions.length === 0) return null
return hasOperations && operationOptions.length > 0 ? (
return (
<div className='relative space-y-1.5'>
<div className='text-[var(--text-primary)] text-small'>Operation</div>
<Combobox
options={operationOptions
.filter((option) => option.id !== '')
.map((option) => ({
label: option.label,
value: option.id,
}))}
value={tool.operation || operationOptions[0].id}
options={operationOptions.map((option) => ({
label: option.label,
value: option.id,
hidden: denied.has(option.id),
}))}
value={
tool.operation ||
operationOptions.find((option) => !denied.has(option.id))?.id
}
onChange={(value) => handleOperationChange(toolIndex, value)}
placeholder='Select operation'
disabled={disabled}
/* Denied operations only drop out once the config
resolves, and picking one rewrites the stored tool. */
disabled={disabled || isPermissionLoading}
/>
</div>
) : null
)
})()}
{(() => {
@@ -17,6 +17,7 @@ import 'reactflow/dist/style.css'
import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { omit } from '@sim/utils/object'
import type { SubflowNodeData } from '@sim/workflow-renderer'
import {
BLOCK_DIMENSIONS,
@@ -40,6 +41,7 @@ import { useSession } from '@/lib/auth/auth-client'
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/base-tool'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import type { OAuthProvider } from '@/lib/oauth'
import { OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access'
import { getDefaultBlockName } from '@/lib/workflows/blocks/canvas-presentation'
import { requestNoteImage, requestNoteRename } from '@/lib/workflows/notes/canvas-requests'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
@@ -126,6 +128,7 @@ import { useUpdateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return'
import { useOperationAccess } from '@/hooks/use-operation-access'
import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useChatStore } from '@/stores/chat/store'
import {
@@ -867,6 +870,8 @@ const WorkflowContent = React.memo(
*/
const pendingFocusBlockIdRef = useRef<string | null>(null)
const { resolveSeedGate } = useOperationAccess()
const addBlock = useCallback(
(
id: string,
@@ -888,6 +893,8 @@ const WorkflowContent = React.memo(
if (parentId) blockData.parentId = parentId
if (extent) blockData.extent = extent
const seedGate = resolveSeedGate(getBlock(type))
const block = prepareBlockState({
id,
type,
@@ -897,6 +904,7 @@ const WorkflowContent = React.memo(
parentId,
extent,
triggerMode,
isSeededValueAllowed: seedGate,
})
const subBlockValues: Record<string, Record<string, unknown>> = {}
@@ -914,7 +922,21 @@ const WorkflowContent = React.memo(
if (!subBlockValues[id]) {
subBlockValues[id] = {}
}
Object.assign(subBlockValues[id], presetSubBlockValues)
/* The same gate as the declared default, deliberately: a preset is
offered by search and the connection picker, whose index reads as
unrestricted until the config resolves — so it is not the informed
pick it looks like, and honouring it would persist an operation
from an unfiltered list. */
const presetOperation = presetSubBlockValues[OPERATION_SUBBLOCK_ID]
const presetOperationDenied =
typeof presetOperation === 'string' && !seedGate(OPERATION_SUBBLOCK_ID, presetOperation)
Object.assign(
subBlockValues[id],
presetOperationDenied
? omit(presetSubBlockValues, [OPERATION_SUBBLOCK_ID])
: presetSubBlockValues
)
}
collaborativeBatchAddBlocks(
@@ -926,7 +948,7 @@ const WorkflowContent = React.memo(
)
usePanelEditorStore.getState().setCurrentBlockId(id)
},
[collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection]
[collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection, resolveSeedGate]
)
const { activeBlockIds, pendingBlocks, isDebugging, isExecuting } = useExecutionStore(
@@ -457,6 +457,7 @@ export const Sidebar = memo(function Sidebar({
config: permissionConfig,
filterBlocks,
isBlockAllowed,
isToolAllowed,
integrationAvailability,
} = usePermissionConfig()
const { navigateToSettings } = useSettingsNavigation()
@@ -472,8 +473,14 @@ export const Sidebar = memo(function Sidebar({
)
useEffect(() => {
initializeSearchData(filterBlocks)
}, [initializeSearchData, filterBlocks, providerModelSignature, customBlockOverlayVersion])
initializeSearchData(filterBlocks, isToolAllowed)
}, [
initializeSearchData,
filterBlocks,
isToolAllowed,
providerModelSignature,
customBlockOverlayVersion,
])
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
+99
View File
@@ -0,0 +1,99 @@
'use client'
import { useMemo } from 'react'
import {
collectDeniedOperationIds,
isOperationAllowed,
MODEL_SUBBLOCK_ID,
NO_DENIED_OPERATIONS,
OPERATION_SUBBLOCK_ID,
type OperationGateBlock,
pickDefaultOperation,
type SeedValueGate,
} from '@/lib/permission-groups/operation-access'
import { usePermissionConfig } from '@/hooks/use-permission-config'
export interface OperationAccess {
/**
* Whether the permission config is still loading. Every list this module
* filters reads as unrestricted until it resolves, so a surface that
* *persists* a pick from one must not accept input while this is true.
*/
isPermissionLoading: boolean
/**
* The operation ids of `block` the caller may not run. Empty while the
* config loads, so pickers show everything rather than flashing a short list.
*/
getDeniedOperations: (
block: OperationGateBlock | null | undefined,
operationIds: Iterable<string>
) => ReadonlySet<string>
/**
* The operation to seed an unset field with: `preferred` when allowed, else
* the first allowed candidate.
*/
resolveDefaultOperation: (
block: OperationGateBlock | null | undefined,
candidates: Iterable<string>,
preferred?: string
) => string | undefined
/**
* A predicate for deciding whether an operation of `block` may be *persisted*
* — or `undefined` while the permission config is still loading.
*
* The withholding is the point. The config resolves as "nothing denied" in
* flight, so a value written during that window would outlive the correction
* that arrives with it. Handing back `undefined` rather than an
* always-`true` predicate means a caller cannot persist without first
* deciding what to do when the answer is unknown.
*/
resolveOperationGate: (
block: OperationGateBlock | null | undefined
) => ((operationId: string) => boolean) | undefined
/**
* The veto `prepareBlockState` applies to a new block's declared defaults.
*
* Creation is one-shot, so unlike the pickers it cannot answer "unknown" by
* waiting — a value written there is never revisited. This gate therefore
* rejects both restricted fields until the config resolves, leaving them
* empty for the pickers to fill, and owns that rule so no caller re-derives
* it. Every other field passes through untouched.
*/
resolveSeedGate: (block: OperationGateBlock | null | undefined) => SeedValueGate
}
/**
* Permission-group access to a block's operations.
*
* The single place the "which operations may this user run, and which one
* should an unset field land on" question is answered, so every surface that
* offers operations — the block editor's dropdown, the agent block's tool list,
* canvas search, block creation — agrees.
*/
export function useOperationAccess(): OperationAccess {
const { isToolAllowed, isModelUsable, isLoading } = usePermissionConfig()
return useMemo(() => {
const isReady = !isLoading
return {
isPermissionLoading: isLoading,
getDeniedOperations: (block, operationIds) =>
isReady
? collectDeniedOperationIds(block, operationIds, isToolAllowed)
: NO_DENIED_OPERATIONS,
resolveDefaultOperation: (block, candidates, preferred) =>
isReady ? pickDefaultOperation(block, candidates, isToolAllowed, preferred) : undefined,
resolveOperationGate: (block) =>
isReady
? (operationId: string) => isOperationAllowed(block, operationId, isToolAllowed)
: undefined,
resolveSeedGate: (block) => (subBlockId, value) => {
if (subBlockId !== OPERATION_SUBBLOCK_ID && subBlockId !== MODEL_SUBBLOCK_ID) return true
if (!isReady) return false
return subBlockId === OPERATION_SUBBLOCK_ID
? isOperationAllowed(block, value, isToolAllowed)
: isModelUsable(value)
},
}
}, [isToolAllowed, isModelUsable, isLoading])
}
+33 -15
View File
@@ -24,6 +24,7 @@ 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
@@ -32,8 +33,12 @@ export interface PermissionConfigResult {
filterBlocks: <T extends { type: string }>(blocks: T[]) => T[]
filterProviders: (providerIds: string[]) => string[]
isBlockAllowed: (blockType: string) => boolean
isProviderAllowed: (providerId: string) => boolean
isModelAllowed: (model: string) => boolean
/**
* Whether a model is usable at all: allowed by the model denylist *and* by
* the provider allowlist. Both gates apply to every model field, so this is
* the only model predicate the interface exposes.
*/
isModelUsable: (model: string) => boolean
isToolAllowed: (toolId: string) => boolean
isInvitationsDisabled: boolean
isPublicApiDisabled: boolean
@@ -120,20 +125,35 @@ export function usePermissionConfig(): PermissionConfigResult {
}
}, [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 isModelAllowed = useMemo(() => {
return (model: string) => !deniedModelSet.has(model.toLowerCase())
}, [deniedModelSet])
const isModelUsable = useMemo(() => {
return (model: string) => {
if (config.deniedModels.length === 0) return true
const normalized = model.toLowerCase()
return !config.deniedModels.some((denied) => denied.toLowerCase() === normalized)
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)
}
}, [config.deniedModels])
}, [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) => {
if (config.deniedTools.length === 0) return true
return !config.deniedTools.includes(toolId)
}
}, [config.deniedTools])
return (toolId: string) => !deniedToolSet.has(toolId)
}, [deniedToolSet])
const filterBlocks = useMemo(() => {
return <T extends { type: string }>(blocks: T[]): T[] => {
@@ -171,8 +191,7 @@ export function usePermissionConfig(): PermissionConfigResult {
filterBlocks,
filterProviders,
isBlockAllowed,
isProviderAllowed,
isModelAllowed,
isModelUsable,
isToolAllowed,
isInvitationsDisabled,
isPublicApiDisabled,
@@ -185,8 +204,7 @@ export function usePermissionConfig(): PermissionConfigResult {
filterBlocks,
filterProviders,
isBlockAllowed,
isProviderAllowed,
isModelAllowed,
isModelUsable,
isToolAllowed,
isInvitationsDisabled,
isPublicApiDisabled,
@@ -0,0 +1,130 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
collectDeniedOperationIds,
isOperationAllowed,
type OperationGateBlock,
pickDefaultOperation,
} from '@/lib/permission-groups/operation-access'
/** A block that resolves its tool from the operation, like most integrations. */
const selectorBlock: OperationGateBlock = {
tools: {
access: ['slack_message', 'slack_canvas', 'slack_read'],
config: {
tool: (params) => {
const map: Record<string, string> = {
send: 'slack_message',
canvas: 'slack_canvas',
read: 'slack_read',
}
const toolId = map[params.operation as string]
if (!toolId) throw new Error(`unknown operation: ${params.operation}`)
return toolId
},
},
},
}
/** A block with no selector, whose operation ids are its tool ids. */
const bareBlock: OperationGateBlock = {
tools: { access: ['sqs_send', 'sqs_receive'] },
}
const singleToolBlock: OperationGateBlock = {
tools: { access: ['dropcontact_enrich_contact'] },
}
const denyAll = () => false
const allowAll = () => true
const deny = (...toolIds: string[]) => {
const denied = new Set(toolIds)
return (toolId: string) => !denied.has(toolId)
}
describe('operation-to-tool resolution', () => {
it('resolves through the block tool selector', () => {
expect(isOperationAllowed(selectorBlock, 'canvas', deny('slack_canvas'))).toBe(false)
expect(isOperationAllowed(selectorBlock, 'canvas', deny('slack_message'))).toBe(true)
})
it('gates on the only tool when the block has no selection to make', () => {
expect(
isOperationAllowed(singleToolBlock, 'anything', deny('dropcontact_enrich_contact'))
).toBe(false)
})
it('treats an operation id as a tool id when the block has no selector', () => {
expect(isOperationAllowed(bareBlock, 'sqs_receive', deny('sqs_receive'))).toBe(false)
expect(isOperationAllowed(bareBlock, 'sqs_receive', deny('sqs_send'))).toBe(true)
})
it('allows rather than guessing when a block has no tools at all', () => {
expect(isOperationAllowed({ tools: { access: [] } }, 'send', denyAll)).toBe(true)
expect(isOperationAllowed(null, 'send', denyAll)).toBe(true)
expect(isOperationAllowed(undefined, 'send', denyAll)).toBe(true)
})
})
describe('isOperationAllowed', () => {
it('denies an operation whose tool the group denies', () => {
expect(isOperationAllowed(selectorBlock, 'canvas', deny('slack_canvas'))).toBe(false)
expect(isOperationAllowed(selectorBlock, 'send', deny('slack_canvas'))).toBe(true)
})
it('allows an unresolvable operation, leaving the server as the gate', () => {
expect(isOperationAllowed(selectorBlock, 'not-an-operation', denyAll)).toBe(true)
})
})
describe('collectDeniedOperationIds', () => {
it('collects only the operations whose tools are denied', () => {
const denied = collectDeniedOperationIds(
selectorBlock,
['send', 'canvas', 'read'],
deny('slack_message', 'slack_read')
)
expect([...denied]).toEqual(['send', 'read'])
})
it('is empty when nothing is denied', () => {
expect(collectDeniedOperationIds(selectorBlock, ['send', 'canvas'], allowAll).size).toBe(0)
})
})
describe('pickDefaultOperation', () => {
const candidates = ['send', 'canvas', 'read']
it('keeps the preferred operation when the group allows it', () => {
expect(pickDefaultOperation(selectorBlock, candidates, allowAll, 'canvas')).toBe('canvas')
})
it('falls back to the first allowed operation when the preferred one is denied', () => {
expect(pickDefaultOperation(selectorBlock, candidates, deny('slack_message'), 'send')).toBe(
'canvas'
)
})
it('takes the first allowed operation when there is no preference', () => {
expect(pickDefaultOperation(selectorBlock, candidates, deny('slack_message'))).toBe('canvas')
})
it('returns undefined when every candidate is denied', () => {
expect(
pickDefaultOperation(
selectorBlock,
candidates,
deny('slack_message', 'slack_canvas', 'slack_read'),
'send'
)
).toBeUndefined()
})
it('keeps a preferred operation it cannot resolve, matching the permissive gate', () => {
expect(pickDefaultOperation(selectorBlock, candidates, denyAll, 'not-an-operation')).toBe(
'not-an-operation'
)
})
})
@@ -0,0 +1,127 @@
import type { BlockConfig } from '@/blocks/types'
/**
* The subblock id that carries a block's operation.
*
* Singular only. Four blocks (`elasticsearch`, `mailchimp`, `onepassword`,
* `typeform`) also declare an `operations` subblock, but it holds a JSON-patch
* payload rather than an operation selector — matching it could only ever key
* a gate off the wrong value.
*/
export const OPERATION_SUBBLOCK_ID = 'operation'
export const MODEL_SUBBLOCK_ID = 'model'
/** Shared empty result, so a caller's memo sees a stable identity. */
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'>
/** Decides whether the caller's permission group allows a concrete tool id. */
export type IsToolAllowed = (toolId: string) => boolean
/**
* Vetoes a subblock's declared default when the caller's permission group does
* not allow it — or when the group config is not known yet, since a default
* written during block creation is never revisited.
*/
export type SeedValueGate = (subBlockId: string, value: string) => boolean
/**
* The tool id a block operation maps to, or `null` when it cannot be resolved
* from the operation alone.
*
* Deliberately not `getToolIdForOperation` from `@/tools/params`: that one ends
* with an unconditional `access[0]` fallback, which for a gate would authorize
* an unrecognized operation against a tool it has nothing to do with. It also
* logs on every selector throw, and this runs once per option of every block
* offered.
*
* Never guesses. A selector that also reads sibling fields throws when handed
* an operation on its own, and an operation the block does not recognize has
* no tool — both yield `null`, which callers read as "not gateable here". The
* server-side gate in `assertPermissionsAllowed` stays authoritative either
* way, so a `null` only ever costs a denied option staying visible, never a
* permitted option disappearing.
*/
function resolveOperationToolId(
block: OperationGateBlock | null | undefined,
operationId: string
): string | null {
const access = block?.tools?.access
if (!access || access.length === 0) return null
/* One tool means there is nothing to select: the block runs that tool
whatever its operation dropdown says. */
if (access.length === 1) return access[0]
const selectTool = block?.tools?.config?.tool
if (selectTool) {
try {
const toolId = selectTool({ operation: operationId })
if (toolId) return toolId
} catch {
/* Unresolvable from the operation alone; see the TSDoc above. */
}
}
/* Blocks with no selector list their tool ids as their operation ids. */
return access.includes(operationId) ? operationId : null
}
/** Whether the caller's permission group allows a block operation. */
export function isOperationAllowed(
block: OperationGateBlock | null | undefined,
operationId: string,
isToolAllowed: IsToolAllowed
): boolean {
const toolId = resolveOperationToolId(block, operationId)
if (!toolId) return true
return isToolAllowed(toolId)
}
/**
* The operation ids of `block` whose tool the caller's permission group denies.
*
* Denied operations are hidden from pickers rather than removed from the model,
* so a workflow that already references one keeps resolving its label.
*/
export function collectDeniedOperationIds(
block: OperationGateBlock | null | undefined,
operationIds: Iterable<string>,
isToolAllowed: IsToolAllowed
): ReadonlySet<string> {
const denied = new Set<string>()
for (const operationId of operationIds) {
if (!isOperationAllowed(block, operationId, isToolAllowed)) {
denied.add(operationId)
}
}
return denied
}
/**
* The operation an unset field should be seeded with: `preferred` when the
* group allows it, otherwise the first candidate it does allow.
*
* Returns `undefined` when every candidate is denied. `useOperationAccess`
* additionally returns `undefined` while the permission config is loading, so a
* caller that seeds only on a defined value can never persist a denied default.
*/
export function pickDefaultOperation(
block: OperationGateBlock | null | undefined,
candidates: Iterable<string>,
isToolAllowed: IsToolAllowed,
preferred?: string
): string | undefined {
if (preferred !== undefined && isOperationAllowed(block, preferred, isToolAllowed)) {
return preferred
}
for (const candidate of candidates) {
if (isOperationAllowed(block, candidate, isToolAllowed)) return candidate
}
return undefined
}
@@ -1,3 +1,4 @@
import { OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access'
import { resolveFieldNoun } from '@/lib/workflows/blocks/canvas-sentence-noun'
import { resolveTriggerSentence } from '@/lib/workflows/blocks/canvas-trigger-sentence'
import type {
@@ -26,16 +27,6 @@ import type {
*/
export type ResolvedSentenceSegment = string | { subBlockId: string; noun?: string }
/**
* The subblock id that carries a block's operation.
*
* Singular only. Four blocks (`elasticsearch`, `mailchimp`, `onepassword`,
* `typeform`) also declare an `operations` subblock, but it holds a JSON-patch
* payload rather than an operation selector — matching it could only ever key
* a sentence off the wrong value.
*/
const OPERATION_SUBBLOCK_ID = 'operation'
type CanvasSentenceConfig = Pick<BlockConfig, 'canvasPresentation' | 'subBlocks'>
/**
+25
View File
@@ -16,6 +16,7 @@ import {
describeModelLevel,
extractAndParseJSON,
filterBlacklistedModels,
findProviderFromModel,
formatCost,
generateStructuredOutputInstructions,
getAllModelProviders,
@@ -2045,3 +2046,27 @@ describe('describeModelLevel', () => {
expect(describeModelLevel('')).toBe('(unset)')
})
})
describe('findProviderFromModel', () => {
it('resolves a chat model to its declaring provider', () => {
expect(findProviderFromModel('claude-sonnet-5')).toBe('anthropic')
expect(findProviderFromModel('gpt-5.2')).toBe('openai')
})
it('is case-insensitive, like getProviderFromModel', () => {
expect(findProviderFromModel('Claude-Sonnet-5')).toBe('anthropic')
})
it('returns null for ids the registry does not declare, instead of guessing ollama', () => {
/* The registry holds chat models only. Speech, image, video and embedding
ids reach `model` subblocks too, and a permission gate must not read them
as Ollama models — see isModelUsable. */
for (const id of ['whisper-1', 'dall-e-3', 'veo-3.1', 'embed-v4.0', 'tts-1']) {
expect(findProviderFromModel(id)).toBeNull()
}
})
it('still lets getProviderFromModel fall back to ollama for those ids', () => {
expect(getProviderFromModel('whisper-1')).toBe('ollama')
})
})
+25 -17
View File
@@ -286,26 +286,34 @@ export function getAllModelProviders(): Record<string, ProviderId> {
)
}
/**
* The provider that declares `model`, or `null` when none does.
*
* The non-guessing half of {@link getProviderFromModel}. A caller that *gates*
* on the answer needs "unknown" to stay distinct from "ollama": this registry
* holds chat models only, so every embedding, speech, image and video model id
* would otherwise read as an Ollama model and be judged against an allowlist
* that was never about it.
*/
export function findProviderFromModel(model: string): ProviderId | null {
const normalizedModel = model.toLowerCase()
const declared = getAllModelProviders()[normalizedModel]
if (declared) return declared
for (const [id, config] of Object.entries(providers)) {
for (const pattern of config.modelPatterns ?? []) {
if (pattern.test(normalizedModel)) return id as ProviderId
}
}
return null
}
export function getProviderFromModel(model: string): ProviderId {
const normalizedModel = model.toLowerCase()
let providerId: ProviderId | null = null
if (normalizedModel in getAllModelProviders()) {
providerId = getAllModelProviders()[normalizedModel]
} else {
for (const [id, config] of Object.entries(providers)) {
if (config.modelPatterns) {
for (const pattern of config.modelPatterns) {
if (pattern.test(normalizedModel)) {
providerId = id as ProviderId
break
}
}
}
if (providerId) break
}
}
let providerId = findProviderFromModel(model)
if (!providerId) {
logger.warn(`No provider found for model: ${model}, defaulting to ollama`)
+4 -1
View File
@@ -136,7 +136,10 @@ describe('search modal store', () => {
mockGetAllBlocks.mockReturnValue([visibleBlock, hiddenBlock])
useSearchModalStore.getState().initializeData((blocks) => blocks)
useSearchModalStore.getState().initializeData(
(blocks) => blocks,
() => true
)
const { tools } = useSearchModalStore.getState().data
expect(tools).toHaveLength(1)
+7 -2
View File
@@ -1,10 +1,11 @@
import { Repeat, Split } from '@sim/emcn/icons'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { isOperationAllowed } from '@/lib/permission-groups/operation-access'
import { toSearchToken } from '@/lib/search/tokens'
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils'
import { getAllBlocks } from '@/blocks'
import { getAllBlocks, getBlock } from '@/blocks'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import type {
SearchBlockItem,
@@ -80,7 +81,7 @@ export const useSearchModalStore = create<SearchModalState>()(
set({ isOpen: false })
},
initializeData: (filterBlocks) => {
initializeData: (filterBlocks, isToolAllowed) => {
const allBlocks = getAllBlocks()
const filteredAllBlocks = filterBlocks(allBlocks) as typeof allBlocks
@@ -158,6 +159,10 @@ export const useSearchModalStore = create<SearchModalState>()(
const allowedBlockTypes = new Set(tools.map((t) => t.type))
const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex()
.filter((op) => allowedBlockTypes.has(op.blockType))
/* Selecting a result drops a block already set to that operation, so
the group's tool denylist has to apply here too — the block-level
allowlist above only decides whether the integration is offered. */
.filter((op) => isOperationAllowed(getBlock(op.blockType), op.operationId, isToolAllowed))
.map((op) => {
const aliasesStr = op.aliases?.length
? ` ${op.aliases.map(toSearchToken).join(' ')}`
+7 -2
View File
@@ -1,4 +1,5 @@
import type { ComponentType } from 'react'
import type { IsToolAllowed } from '@/lib/permission-groups/operation-access'
import type { BlockConfig } from '@/blocks/types'
/**
@@ -78,7 +79,11 @@ export interface SearchModalState {
close: () => void
/**
* Initialize search data. Called once on app load.
* Initialize search data. Re-runs whenever the caller's permission config
* resolves or changes, since both predicates are derived from it.
*/
initializeData: (filterBlocks: <T extends { type: string }>(blocks: T[]) => T[]) => void
initializeData: (
filterBlocks: <T extends { type: string }>(blocks: T[]) => T[],
isToolAllowed: IsToolAllowed
) => void
}
+102 -2
View File
@@ -7,9 +7,10 @@ import {
createStarterBlock,
} from '@sim/testing'
import type { Edge } from 'reactflow'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getBlock } from '@/blocks/registry'
import { normalizeName } from '@/executor/constants'
import { filterNewEdges, getUniqueBlockName, regenerateBlockIds } from './utils'
import { filterNewEdges, getUniqueBlockName, prepareBlockState, regenerateBlockIds } from './utils'
describe('normalizeName', () => {
it.concurrent('should convert to lowercase', () => {
@@ -1063,3 +1064,102 @@ describe('regenerateBlockIds — cloned webhook path', () => {
expect(result.subBlockValues[newId].token).toBe('user-secret')
})
})
describe('prepareBlockState — permission-group seed veto', () => {
const blockWithDefaults = {
name: 'Mock Block',
description: '',
icon: () => null,
outputs: {},
tools: { access: ['slack_message'] },
subBlocks: [
{ id: 'operation', type: 'dropdown', defaultValue: 'send' },
{ id: 'model', type: 'combobox', defaultValue: 'claude-sonnet-5' },
{ id: 'channel', type: 'short-input', defaultValue: '#general' },
{ id: 'blank', type: 'short-input', defaultValue: '' },
{ id: 'headers', type: 'table', defaultValue: [] },
],
}
const seededValues = (isSeededValueAllowed?: (subBlockId: string, value: string) => boolean) => {
vi.mocked(getBlock).mockReturnValueOnce(blockWithDefaults as never)
const block = prepareBlockState({
id: 'b1',
type: 'slack',
name: 'Slack',
position: { x: 0, y: 0 },
isSeededValueAllowed,
})
return Object.fromEntries(
Object.entries(block.subBlocks).map(([id, subBlock]) => [id, subBlock.value])
)
}
afterEach(() => {
vi.mocked(getBlock).mockReset()
})
it('seeds every declared default when no gate is supplied', () => {
expect(seededValues()).toEqual({
operation: 'send',
model: 'claude-sonnet-5',
channel: '#general',
blank: '',
headers: [],
})
})
it('seeds every declared default when the gate allows them', () => {
expect(seededValues(() => true)).toEqual({
operation: 'send',
model: 'claude-sonnet-5',
channel: '#general',
blank: '',
headers: [],
})
})
it('never consults the gate for an empty or non-string default', () => {
/* Both are "nothing was declared" rather than a value to authorize, and a
gate that saw them would veto every unfilled field. */
const seen: string[] = []
seededValues((subBlockId) => {
seen.push(subBlockId)
return true
})
expect(seen).not.toContain('blank')
expect(seen).not.toContain('headers')
})
it('keeps an empty or non-string default even when the gate rejects everything', () => {
const values = seededValues(() => false)
expect(values.blank).toBe('')
expect(values.headers).toEqual([])
})
it('leaves a denied operation unseeded rather than substituting one', () => {
const values = seededValues((subBlockId) => subBlockId !== 'operation')
expect(values.operation).toBeNull()
expect(values.model).toBe('claude-sonnet-5')
expect(values.channel).toBe('#general')
})
it('leaves a denied model unseeded', () => {
const values = seededValues((subBlockId) => subBlockId !== 'model')
expect(values.model).toBeNull()
expect(values.operation).toBe('send')
})
it('passes the seeded value to the gate, not just the field id', () => {
const seen: Array<[string, string]> = []
seededValues((subBlockId, value) => {
seen.push([subBlockId, value])
return true
})
expect(seen).toEqual([
['operation', 'send'],
['model', 'claude-sonnet-5'],
['channel', '#general'],
])
})
})
+38 -1
View File
@@ -2,6 +2,7 @@ import { generateId } from '@sim/utils/id'
import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks'
import { filterUniqueWorkflowEdges } from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
import type { SeedValueGate } from '@/lib/permission-groups/operation-access'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
@@ -101,6 +102,23 @@ export interface PrepareBlockStateOptions {
parentId?: string
extent?: 'parent'
triggerMode?: boolean
/**
* Vetoes a declared default that the creator's permission group denies —
* today the `operation` and `model` fields, both of which blocks pre-fill.
*
* A vetoed field is seeded with nothing rather than a substitute. The editor's
* own permission-aware pickers already resolve the right replacement (first
* allowed operation; preferred-then-first allowed model) and they only fill a
* field that is empty, so leaving it empty hands the choice to the one place
* that knows how to make it. Substituting here instead would also drift from
* `getDefaultBlockName`, which names the block after its *declared* default.
*
* Omit it entirely only where permission gating does not apply, in which case
* declared defaults are seeded unchanged. A caller that cannot yet answer —
* config still loading — vetoes rather than omitting, since a value written
* here is never revisited.
*/
isSeededValueAllowed?: SeedValueGate
}
/**
@@ -108,7 +126,17 @@ export interface PrepareBlockStateOptions {
* Generates subBlocks and outputs from the block registry.
*/
export function prepareBlockState(options: PrepareBlockStateOptions): BlockState {
const { id, type, name, position, data, parentId, extent, triggerMode = false } = options
const {
id,
type,
name,
position,
data,
parentId,
extent,
triggerMode = false,
isSeededValueAllowed,
} = options
const blockConfig = getBlock(type)
@@ -153,6 +181,15 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
initialValue = []
}
if (
isSeededValueAllowed &&
typeof initialValue === 'string' &&
initialValue !== '' &&
!isSeededValueAllowed(subBlock.id, initialValue)
) {
initialValue = null
}
subBlocks[subBlock.id] = {
id: subBlock.id,
type: subBlock.type,