From 0eb265be0cf2d017ca6781db16f03896a7449776 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 18:43:02 -0700 Subject: [PATCH 1/5] fix(security): redact opaque workflow snapshot inputs --- apps/docs/openapi-v2-logs.json | 2 +- apps/docs/openapi-v2-workflows.json | 2 +- .../[id]/versions/[version]/route.test.ts | 39 ++++++ apps/sim/lib/api/contracts/v2/logs.ts | 2 +- apps/sim/lib/api/contracts/v2/workflows.ts | 2 +- .../application/public-log-use-cases.test.ts | 40 ++++++ apps/sim/lib/logs/snapshot-sanitizer.ts | 5 +- .../application/read-workflow-version.ts | 8 +- .../credentials/credential-extractor.test.ts | 85 ++++++++++++ .../credentials/credential-extractor.ts | 130 +++++++++++++----- .../workflows/search-replace/indexer.test.ts | 19 ++- .../lib/workflows/search-replace/indexer.ts | 22 ++- 12 files changed, 308 insertions(+), 48 deletions(-) diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 26c5cff0b2c..8833ac8b13e 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -1206,7 +1206,7 @@ "type": "null" } ], - "description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input` values and `password: true` sub-block values are null, while `{{VAR}}` environment-variable references are preserved. Null when no snapshot is retained." + "description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained." }, "traceSpans": { "type": "array", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index e0ff219ba13..81afff72768 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2876,7 +2876,7 @@ "format": "date-time" }, "state": { - "description": "Deployed workflow graph snapshot pinned by this version.", + "description": "Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null, and sensitive nested tool parameters are null.", "$ref": "#/components/schemas/DeployedWorkflowState" } }, diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts index 7e268fab876..cfe78176f48 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts @@ -29,6 +29,19 @@ vi.mock('@/lib/workflows/application/context', () => ({ vi.mock('@/lib/workflows/persistence/utils', () => ({ getWorkflowDeploymentVersion: mocks.readVersion, })) +vi.mock('@/lib/workflows/search-replace/indexer', () => ({ + getToolInputParamConfigs: ({ tool }: { tool: { params?: Record } }) => + Object.entries(tool.params ?? {}).map(([paramId, value]) => ({ + paramId, + authoritative: true, + value, + config: { + id: paramId, + type: 'short-input', + password: paramId === 'apiKey', + }, + })), +})) vi.mock('@/blocks/registry', () => ({ getBlock: () => ({ name: 'Slack', @@ -36,6 +49,8 @@ vi.mock('@/blocks/registry', () => ({ { id: 'credential', type: 'oauth-input' }, { id: 'botToken', type: 'short-input', password: true }, { id: 'envToken', type: 'short-input', password: true }, + { id: 'tools', type: 'tool-input' }, + { id: 'headers', type: 'table' }, { id: 'channel', type: 'short-input' }, ], outputs: {}, @@ -88,6 +103,21 @@ function versionState() { credential: { id: 'credential', type: 'oauth-input', value: 'oauth-credential-id' }, botToken: { id: 'botToken', type: 'short-input', value: 'xoxb-plaintext-secret' }, envToken: { id: 'envToken', type: 'short-input', value: '{{SLACK_BOT_TOKEN}}' }, + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'custom-tool', + params: { apiKey: 'sk-tool-plaintext-secret', query: 'safe input' }, + }, + ], + }, + headers: { + id: 'headers', + type: 'table', + value: [{ Key: 'Authorization', Value: 'Bearer table-plaintext-secret' }], + }, channel: { id: 'channel', type: 'short-input', value: '#general' }, }, }, @@ -149,6 +179,15 @@ describe('GET /api/v2/workflows/[id]/versions/[version]', () => { expect(subBlocks.credential.value).toBeNull() expect(subBlocks.botToken.value).toBeNull() expect(subBlocks.envToken.value).toBe('{{SLACK_BOT_TOKEN}}') + expect(subBlocks.tools.value).toEqual([ + { + type: 'custom-tool', + params: { apiKey: null, query: 'safe input' }, + }, + ]) + expect(subBlocks.headers.value).toBeNull() expect(subBlocks.channel.value).toBe('#general') + expect(JSON.stringify(subBlocks)).not.toContain('sk-tool-plaintext-secret') + expect(JSON.stringify(subBlocks)).not.toContain('table-plaintext-secret') }) }) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 1f2c76e069e..569b893b5c3 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -74,7 +74,7 @@ const v2LogWorkflowStateSchema = z ) .nullable() .describe( - 'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input` values and `password: true` sub-block values are null, while `{{VAR}}` environment-variable references are preserved. Null when no snapshot is retained.' + 'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained.' ) const v2LogWorkflowSummarySchema = z.object({ diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index d5df0af5f4f..a3cf26bdc30 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -615,7 +615,7 @@ export const v2WorkflowVersionDetailSchema = z .describe('ISO 8601 timestamp when this version was created.') .meta({ format: 'date-time' }), state: deployedWorkflowStateSchema.describe( - 'Deployed workflow graph snapshot pinned by this version.' + 'Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null, and sensitive nested tool parameters are null.' ), }) .meta({ diff --git a/apps/sim/lib/logs/application/public-log-use-cases.test.ts b/apps/sim/lib/logs/application/public-log-use-cases.test.ts index 2ecb4fed7ea..d8aac016109 100644 --- a/apps/sim/lib/logs/application/public-log-use-cases.test.ts +++ b/apps/sim/lib/logs/application/public-log-use-cases.test.ts @@ -43,6 +43,20 @@ vi.mock('@/lib/logs/execution/trace-store', () => ({ materializeExecutionDataForDisplay: mocks.materialize, })) +vi.mock('@/lib/workflows/search-replace/indexer', () => ({ + getToolInputParamConfigs: ({ tool }: { tool: { params?: Record } }) => + Object.entries(tool.params ?? {}).map(([paramId, value]) => ({ + paramId, + authoritative: true, + value, + config: { + id: paramId, + type: 'short-input', + password: paramId === 'apiKey', + }, + })), +})) + vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) /** @@ -56,6 +70,8 @@ vi.mock('@/blocks/registry', () => ({ { id: 'credential', type: 'oauth-input' }, { id: 'botToken', type: 'short-input', password: true }, { id: 'envToken', type: 'short-input', password: true }, + { id: 'tools', type: 'tool-input' }, + { id: 'headers', type: 'table' }, { id: 'channel', type: 'short-input' }, ], outputs: {}, @@ -155,6 +171,21 @@ describe('public log application use cases', () => { credential: { id: 'credential', type: 'oauth-input', value: 'cred_9f2a' }, botToken: { id: 'botToken', type: 'short-input', value: 'xoxb-plaintext-secret' }, envToken: { id: 'envToken', type: 'short-input', value: '{{SLACK_TOKEN}}' }, + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'custom-tool', + params: { apiKey: 'sk-log-tool-secret', query: 'safe input' }, + }, + ], + }, + headers: { + id: 'headers', + type: 'table', + value: [{ Key: 'Authorization', Value: 'Bearer log-table-secret' }], + }, channel: { id: 'channel', type: 'short-input', value: '#general' }, }, }, @@ -177,7 +208,16 @@ describe('public log application use cases', () => { expect(subBlocks.credential.value).toBeNull() expect(subBlocks.botToken.value).toBeNull() expect(subBlocks.envToken.value).toBe('{{SLACK_TOKEN}}') + expect(subBlocks.tools.value).toEqual([ + { + type: 'custom-tool', + params: { apiKey: null, query: 'safe input' }, + }, + ]) + expect(subBlocks.headers.value).toBeNull() expect(subBlocks.channel.value).toBe('#general') + expect(JSON.stringify(subBlocks)).not.toContain('sk-log-tool-secret') + expect(JSON.stringify(subBlocks)).not.toContain('log-table-secret') }) it('passes the personal-key subject through as the projection reader', async () => { diff --git a/apps/sim/lib/logs/snapshot-sanitizer.ts b/apps/sim/lib/logs/snapshot-sanitizer.ts index f086f853103..cf8ef5cdafe 100644 --- a/apps/sim/lib/logs/snapshot-sanitizer.ts +++ b/apps/sim/lib/logs/snapshot-sanitizer.ts @@ -19,5 +19,8 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' */ export function sanitizeExecutionSnapshotState(state: unknown): Record | null { if (typeof state !== 'object' || state === null) return null - return sanitizeWorkflowForSharing(state as Partial, { preserveEnvVars: true }) + return sanitizeWorkflowForSharing(state as Partial, { + preserveEnvVars: true, + redactOpaqueCredentialInputs: true, + }) } diff --git a/apps/sim/lib/workflows/application/read-workflow-version.ts b/apps/sim/lib/workflows/application/read-workflow-version.ts index 65ee9e094d1..1874fba52b7 100644 --- a/apps/sim/lib/workflows/application/read-workflow-version.ts +++ b/apps/sim/lib/workflows/application/read-workflow-version.ts @@ -20,10 +20,14 @@ function isWorkflowState(value: unknown): value is WorkflowState { * * `preserveEnvVars` keeps `{{VAR}}` references: those name a workspace environment variable * rather than carrying its value — resolution happens at execution time — so the reference is - * not a secret and is what keeps the pinned graph diffable. Literal inline secrets are nulled. + * not a secret and is what keeps the pinned graph diffable. Literal inline secrets, opaque table + * cells, and sensitive nested tool parameters are nulled. */ function sanitizeVersionState(state: WorkflowState): WorkflowState { - const sanitized = sanitizeWorkflowForSharing(state, { preserveEnvVars: true }) + const sanitized = sanitizeWorkflowForSharing(state, { + preserveEnvVars: true, + redactOpaqueCredentialInputs: true, + }) // double-cast-allowed: the sanitizer clones the graph and only nulls sub-block values, so the shape is unchanged, but its widened return type no longer overlaps WorkflowState return sanitized as unknown as WorkflowState } diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts index 7f609589d0e..e83b9c9415c 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts @@ -5,11 +5,28 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { EXPORT_PRESERVED_RESOURCE_TYPES, sanitizeForExport, + sanitizeWorkflowForSharing, } from '@/lib/workflows/credentials/credential-extractor' import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry' import { getBlock } from '@/blocks/registry' import type { WorkflowState } from '@/stores/workflows/workflow/types' +vi.mock('@/lib/workflows/search-replace/indexer', () => ({ + getToolInputParamConfigs: ({ tool }: { tool: { params?: Record } }) => + Object.entries(tool.params ?? {}) + .filter(([paramId]) => paramId !== 'unclassified') + .map(([paramId, value]) => ({ + paramId, + authoritative: true, + value, + config: { + id: paramId, + type: 'short-input', + password: paramId === 'apiKey' || paramId === 'token', + }, + })), +})) + function stateWithSubBlock(type: string, value: unknown): Partial { return { blocks: { @@ -93,4 +110,72 @@ describe('export sanitizer resource coverage', () => { } as unknown as Partial) expect(sanitized.blocks?.b1?.subBlocks?.tableId?.value).toBeNull() }) + + it('uses tool-input codecs to withhold secret params while preserving safe config', () => { + const value = [ + { + type: 'custom-tool', + customToolId: 'tool-1', + params: { + apiKey: 'sk-plaintext-secret', + query: 'safe input', + unclassified: 'must-not-pass-through', + }, + }, + ] + vi.mocked(getBlock).mockReturnValue({ + name: 'Test', + description: '', + subBlocks: [{ id: 'field', title: 'Field', type: 'tool-input' }], + outputs: {}, + } as never) + + const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('tool-input', value), { + preserveEnvVars: true, + redactOpaqueCredentialInputs: true, + }) + + expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([ + { + type: 'custom-tool', + customToolId: 'tool-1', + params: { apiKey: null, query: 'safe input', unclassified: null }, + }, + ]) + }) + + it('withholds opaque table values from public snapshots', () => { + const value = [ + { Key: 'Authorization', Value: 'Bearer plaintext-secret' }, + { Key: 'API_TOKEN', Value: '{{API_TOKEN}}' }, + ] + vi.mocked(getBlock).mockReturnValue({ + name: 'Test', + description: '', + subBlocks: [{ id: 'field', title: 'Field', type: 'table' }], + outputs: {}, + } as never) + + const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('table', value), { + preserveEnvVars: true, + redactOpaqueCredentialInputs: true, + }) + + expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toBeNull() + }) + + it('withholds an opaque persisted type when the block is no longer registered', () => { + vi.mocked(getBlock).mockReturnValue(undefined as never) + + const sanitized = sanitizeWorkflowForSharing( + stateWithSubBlock('tool-input', [ + { type: 'custom-tool', params: { token: 'plaintext-secret' } }, + ]), + { redactOpaqueCredentialInputs: true } + ) + + expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([ + { type: 'custom-tool', params: { token: null } }, + ]) + }) }) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index 8e9e99bd9c1..b8d2fc4b425 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -1,4 +1,6 @@ +import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry' +import { setValueAtPath } from '@/lib/workflows/search-replace/value-walker' import { buildCanonicalIndex, buildSubBlockValues, @@ -8,6 +10,7 @@ import { isSubBlockVisibleForMode, type SubBlockCondition, } from '@/lib/workflows/subblocks/visibility' +import { parseStoredToolInputValue } from '@/lib/workflows/tool-input/types' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' @@ -77,6 +80,15 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([ 'folderId', ]) +/** + * Sub-block values whose interior cannot be projected safely for a read-only snapshot API. + * + * Tables are arbitrary key/value rows used for authorization headers and sandbox environment + * variables. Their cells carry no password metadata, so public snapshots must withhold the whole + * value. Tool inputs are handled separately through the search-replace parameter codecs. + */ +const OPAQUE_CREDENTIAL_BEARING_TYPES: ReadonlySet = new Set(['table']) + /** * Extract required credentials from a workflow state * This analyzes all blocks and their subblocks to identify credential requirements @@ -192,9 +204,13 @@ function formatFieldName(fieldName: string): string { .join(' ') } +interface MutableSubBlockState extends Omit { + value: unknown +} + /** Block state with mutable subBlocks for sanitization */ interface MutableBlockState extends Omit { - subBlocks: Record + subBlocks: Record data?: Record } @@ -248,6 +264,72 @@ interface SanitizedWorkflowState { [key: string]: unknown } +interface WorkflowSanitizationOptions { + preserveEnvVars?: boolean + redactOpaqueCredentialInputs?: boolean +} + +type CredentialSanitizationConfig = Pick< + SubBlockConfig, + 'id' | 'type' | 'password' | 'canonicalParamId' +> + +function isEnvironmentVariableReference(value: unknown): value is string { + return typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}') +} + +function sanitizeToolInputValue(value: unknown, options: WorkflowSanitizationOptions): unknown { + const tools = parseStoredToolInputValue(value) + if (!Array.isArray(value)) return null + if (tools.length !== value.length) return null + + let sanitizedValue: unknown = value + tools.forEach((tool, toolIndex) => { + const configs = getToolInputParamConfigs({ tool, toolIndex }) + const configByParamKey = new Map< + string, + { config: CredentialSanitizationConfig; authoritative: boolean } + >() + configs.forEach(({ paramId, config, authoritative }) => { + configByParamKey.set(paramId, { config, authoritative }) + if (config.canonicalParamId) { + configByParamKey.set(config.canonicalParamId, { config, authoritative }) + } + }) + + Object.entries(tool.params ?? {}).forEach(([paramKey, paramValue]) => { + const resolved = configByParamKey.get(paramKey) + const nextValue = resolved?.authoritative + ? sanitizeConfiguredSubBlockValue(paramValue, resolved.config, options) + : null + sanitizedValue = setValueAtPath(sanitizedValue, [toolIndex, 'params', paramKey], nextValue) + }) + }) + + return sanitizedValue +} + +function sanitizeConfiguredSubBlockValue( + value: unknown, + config: CredentialSanitizationConfig, + options: WorkflowSanitizationOptions +): unknown { + if (config.type === 'oauth-input') return null + if (options.redactOpaqueCredentialInputs && config.type === 'tool-input') { + return sanitizeToolInputValue(value, options) + } + if (options.redactOpaqueCredentialInputs && OPAQUE_CREDENTIAL_BEARING_TYPES.has(config.type)) { + return null + } + if (config.password === true) { + return options.preserveEnvVars && isEnvironmentVariableReference(value) ? value : null + } + if (WORKSPACE_SPECIFIC_TYPES.has(config.type) || WORKSPACE_SPECIFIC_FIELDS.has(config.id)) { + return null + } + return value +} + /** * Sanitize workflow state by removing all credentials and workspace-specific data * This is used for both template creation and workflow export to ensure consistency @@ -257,9 +339,7 @@ interface SanitizedWorkflowState { */ export function sanitizeWorkflowForSharing( state: Partial | null | undefined, - options: { - preserveEnvVars?: boolean // Keep {{VAR}} references for export - } = {} + options: WorkflowSanitizationOptions = {} ): SanitizedWorkflowState { const sanitized = structuredClone(state) as SanitizedWorkflowState @@ -281,35 +361,11 @@ export function sanitizeWorkflowForSharing( if (block.subBlocks?.[subBlockConfig.id]) { const subBlock = block.subBlocks[subBlockConfig.id] - // Clear OAuth credentials (type: 'oauth-input') - if (subBlockConfig.type === 'oauth-input') { - block.subBlocks[subBlockConfig.id]!.value = null - } - - // Clear secret fields (password: true) - else if (subBlockConfig.password === true) { - // Preserve environment variable references if requested - if ( - options.preserveEnvVars && - typeof subBlock?.value === 'string' && - subBlock.value.startsWith('{{') && - subBlock.value.endsWith('}}') - ) { - // Keep the env var reference - } else { - block.subBlocks[subBlockConfig.id]!.value = null - } - } - - // Clear workspace-specific selectors - else if (WORKSPACE_SPECIFIC_TYPES.has(subBlockConfig.type)) { - block.subBlocks[subBlockConfig.id]!.value = null - } - - // Clear workspace-specific fields by ID - else if (WORKSPACE_SPECIFIC_FIELDS.has(subBlockConfig.id)) { - block.subBlocks[subBlockConfig.id]!.value = null - } + block.subBlocks[subBlockConfig.id]!.value = sanitizeConfiguredSubBlockValue( + subBlock?.value, + subBlockConfig, + options + ) } }) } @@ -317,6 +373,14 @@ export function sanitizeWorkflowForSharing( // Process subBlocks without config (fallback) if (block.subBlocks) { Object.entries(block.subBlocks).forEach(([key, subBlock]) => { + if (options.redactOpaqueCredentialInputs && subBlock) { + if (subBlock.type === 'tool-input') { + subBlock.value = sanitizeToolInputValue(subBlock.value, options) + } else if (OPAQUE_CREDENTIAL_BEARING_TYPES.has(subBlock.type)) { + subBlock.value = null + } + } + // Clear workspace-specific fields by key name if (WORKSPACE_SPECIFIC_FIELDS.has(key) && subBlock) { subBlock.value = null diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index e970b80c787..c908b725088 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -2,7 +2,10 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer' +import { + getToolInputParamConfigs, + indexWorkflowSearchMatches, +} from '@/lib/workflows/search-replace/indexer' import { workflowSearchMatchMatchesQuery } from '@/lib/workflows/search-replace/resources' import { createSearchReplaceWorkflowFixture, @@ -22,6 +25,20 @@ import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replac vi.unmock('@/tools/registry') describe('indexWorkflowSearchMatches', () => { + it('marks generic tool-param fallbacks as non-authoritative', () => { + expect( + getToolInputParamConfigs({ + tool: { type: 'custom-tool', params: { apiKey: 'literal-secret' } }, + }) + ).toEqual([ + expect.objectContaining({ + paramId: 'apiKey', + authoritative: false, + value: 'literal-secret', + }), + ]) + }) + it('finds plain text matches across nested subblock values', () => { const workflow = createSearchReplaceWorkflowFixture() diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index bd7ac997b09..960d447b8bb 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -668,6 +668,16 @@ function isVisibleToolParameter(param: ToolParameterConfig, values: Record blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs'] -}): Array<{ - paramId: string - config: WorkflowSearchSubBlockConfig - value: unknown - selectorContext?: SelectorContext - dependentValuePaths?: WorkflowSearchValuePath[] -}> { +}): ResolvedToolInputParamConfig[] { const toolId = tool.type !== 'custom-tool' && tool.type !== 'mcp' ? getToolIdForOperation(tool.type, tool.operation) || tool.toolId @@ -715,6 +719,7 @@ export function getToolInputParamConfigs({ const type = getFallbackToolParamType(value) return { paramId, + authoritative: false, config: { id: paramId, title: paramId, @@ -759,6 +764,7 @@ export function getToolInputParamConfigs({ const config = buildToolInputSearchConfig(param) return { paramId: param.id, + authoritative: true, config, value: parseToolParamValue(toolParamValues[param.id], config.type), selectorContext: @@ -811,6 +817,7 @@ export function getToolInputParamConfigs({ const subBlockParams = visibleSubBlocks.map((config) => ({ paramId: config.id, + authoritative: true, config, value: parseToolParamValue(toolParamValues[config.id], config.type), dependentValuePaths: getDependentValuePaths(config.id), @@ -830,6 +837,7 @@ export function getToolInputParamConfigs({ const config = buildToolInputSearchConfig(param) return { paramId: param.id, + authoritative: true, config, value: parseToolParamValue(toolParamValues[param.id], config.type), selectorContext: From 275a9e879a47b8383089f829fc06f8e157b826fd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 19:19:35 -0700 Subject: [PATCH 2/5] fix(security): document fail-closed tool redaction --- apps/docs/openapi-v2-logs.json | 2 +- apps/docs/openapi-v2-workflows.json | 2 +- .../[id]/versions/[version]/route.test.ts | 10 ++-- apps/sim/lib/api/contracts/v2/logs.ts | 2 +- apps/sim/lib/api/contracts/v2/workflows.ts | 2 +- .../application/public-log-use-cases.test.ts | 10 ++-- apps/sim/lib/logs/snapshot-sanitizer.ts | 3 +- .../application/read-workflow-version.ts | 3 +- .../credentials/credential-extractor.test.ts | 52 +++++++++++-------- .../credentials/credential-extractor.ts | 5 ++ 10 files changed, 56 insertions(+), 35 deletions(-) diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 8833ac8b13e..6ee095fb60b 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -1206,7 +1206,7 @@ "type": "null" } ], - "description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained." + "description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained." }, "traceSpans": { "type": "array", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 81afff72768..3d650764b1a 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2876,7 +2876,7 @@ "format": "date-time" }, "state": { - "description": "Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null, and sensitive nested tool parameters are null.", + "description": "Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.", "$ref": "#/components/schemas/DeployedWorkflowState" } }, diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts index cfe78176f48..909c823e864 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts @@ -30,10 +30,14 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ getWorkflowDeploymentVersion: mocks.readVersion, })) vi.mock('@/lib/workflows/search-replace/indexer', () => ({ - getToolInputParamConfigs: ({ tool }: { tool: { params?: Record } }) => + getToolInputParamConfigs: ({ + tool, + }: { + tool: { type: string; params?: Record } + }) => Object.entries(tool.params ?? {}).map(([paramId, value]) => ({ paramId, - authoritative: true, + authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp', value, config: { id: paramId, @@ -182,7 +186,7 @@ describe('GET /api/v2/workflows/[id]/versions/[version]', () => { expect(subBlocks.tools.value).toEqual([ { type: 'custom-tool', - params: { apiKey: null, query: 'safe input' }, + params: { apiKey: null, query: null }, }, ]) expect(subBlocks.headers.value).toBeNull() diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 569b893b5c3..7e01f7c60be 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -74,7 +74,7 @@ const v2LogWorkflowStateSchema = z ) .nullable() .describe( - 'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained.' + 'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained.' ) const v2LogWorkflowSummarySchema = z.object({ diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index a3cf26bdc30..2359af203b2 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -615,7 +615,7 @@ export const v2WorkflowVersionDetailSchema = z .describe('ISO 8601 timestamp when this version was created.') .meta({ format: 'date-time' }), state: deployedWorkflowStateSchema.describe( - 'Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null, and sensitive nested tool parameters are null.' + 'Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.' ), }) .meta({ diff --git a/apps/sim/lib/logs/application/public-log-use-cases.test.ts b/apps/sim/lib/logs/application/public-log-use-cases.test.ts index d8aac016109..7c3e2df47bd 100644 --- a/apps/sim/lib/logs/application/public-log-use-cases.test.ts +++ b/apps/sim/lib/logs/application/public-log-use-cases.test.ts @@ -44,10 +44,14 @@ vi.mock('@/lib/logs/execution/trace-store', () => ({ })) vi.mock('@/lib/workflows/search-replace/indexer', () => ({ - getToolInputParamConfigs: ({ tool }: { tool: { params?: Record } }) => + getToolInputParamConfigs: ({ + tool, + }: { + tool: { type: string; params?: Record } + }) => Object.entries(tool.params ?? {}).map(([paramId, value]) => ({ paramId, - authoritative: true, + authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp', value, config: { id: paramId, @@ -211,7 +215,7 @@ describe('public log application use cases', () => { expect(subBlocks.tools.value).toEqual([ { type: 'custom-tool', - params: { apiKey: null, query: 'safe input' }, + params: { apiKey: null, query: null }, }, ]) expect(subBlocks.headers.value).toBeNull() diff --git a/apps/sim/lib/logs/snapshot-sanitizer.ts b/apps/sim/lib/logs/snapshot-sanitizer.ts index cf8ef5cdafe..90fe3cd37a5 100644 --- a/apps/sim/lib/logs/snapshot-sanitizer.ts +++ b/apps/sim/lib/logs/snapshot-sanitizer.ts @@ -11,7 +11,8 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' * * `preserveEnvVars` keeps `{{VAR}}` references, which name a workspace environment variable * rather than carrying its value — resolution happens at execution time — so the reference is - * not a secret and is what keeps consecutive run snapshots diffable. + * not a secret and is what keeps consecutive run snapshots diffable. Tool parameters without + * authoritative codec metadata are withheld rather than guessed safe. * * A run with no retained snapshot projects as `null`, and so does a stored value that is not an * object: the sanitizer can make no guarantee about a shape it cannot walk, so it is withheld diff --git a/apps/sim/lib/workflows/application/read-workflow-version.ts b/apps/sim/lib/workflows/application/read-workflow-version.ts index 1874fba52b7..4cab173c1a2 100644 --- a/apps/sim/lib/workflows/application/read-workflow-version.ts +++ b/apps/sim/lib/workflows/application/read-workflow-version.ts @@ -21,7 +21,8 @@ function isWorkflowState(value: unknown): value is WorkflowState { * `preserveEnvVars` keeps `{{VAR}}` references: those name a workspace environment variable * rather than carrying its value — resolution happens at execution time — so the reference is * not a secret and is what keeps the pinned graph diffable. Literal inline secrets, opaque table - * cells, and sensitive nested tool parameters are nulled. + * cells, sensitive nested tool parameters, and tool parameters without authoritative codec + * metadata are nulled. */ function sanitizeVersionState(state: WorkflowState): WorkflowState { const sanitized = sanitizeWorkflowForSharing(state, { diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts index e83b9c9415c..0362c69b7ab 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts @@ -12,19 +12,21 @@ import { getBlock } from '@/blocks/registry' import type { WorkflowState } from '@/stores/workflows/workflow/types' vi.mock('@/lib/workflows/search-replace/indexer', () => ({ - getToolInputParamConfigs: ({ tool }: { tool: { params?: Record } }) => - Object.entries(tool.params ?? {}) - .filter(([paramId]) => paramId !== 'unclassified') - .map(([paramId, value]) => ({ - paramId, - authoritative: true, - value, - config: { - id: paramId, - type: 'short-input', - password: paramId === 'apiKey' || paramId === 'token', - }, - })), + getToolInputParamConfigs: ({ + tool, + }: { + tool: { type: string; params?: Record } + }) => + Object.entries(tool.params ?? {}).map(([paramId, value]) => ({ + paramId, + authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp', + value, + config: { + id: paramId, + type: 'short-input', + password: paramId === 'apiKey' || paramId === 'token', + }, + })), })) function stateWithSubBlock(type: string, value: unknown): Partial { @@ -111,15 +113,15 @@ describe('export sanitizer resource coverage', () => { expect(sanitized.blocks?.b1?.subBlocks?.tableId?.value).toBeNull() }) - it('uses tool-input codecs to withhold secret params while preserving safe config', () => { + it('uses authoritative tool-input codecs to withhold secrets while preserving safe config', () => { const value = [ { - type: 'custom-tool', - customToolId: 'tool-1', + type: 'gmail', + toolId: 'gmail_send', + operation: 'send_gmail', params: { apiKey: 'sk-plaintext-secret', query: 'safe input', - unclassified: 'must-not-pass-through', }, }, ] @@ -137,9 +139,10 @@ describe('export sanitizer resource coverage', () => { expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([ { - type: 'custom-tool', - customToolId: 'tool-1', - params: { apiKey: null, query: 'safe input', unclassified: null }, + type: 'gmail', + toolId: 'gmail_send', + operation: 'send_gmail', + params: { apiKey: null, query: 'safe input' }, }, ]) }) @@ -164,18 +167,21 @@ describe('export sanitizer resource coverage', () => { expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toBeNull() }) - it('withholds an opaque persisted type when the block is no longer registered', () => { + it('withholds every unclassified custom-tool parameter', () => { vi.mocked(getBlock).mockReturnValue(undefined as never) const sanitized = sanitizeWorkflowForSharing( stateWithSubBlock('tool-input', [ - { type: 'custom-tool', params: { token: 'plaintext-secret' } }, + { + type: 'custom-tool', + params: { token: 'plaintext-secret', query: 'ordinary configuration' }, + }, ]), { redactOpaqueCredentialInputs: true } ) expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([ - { type: 'custom-tool', params: { token: null } }, + { type: 'custom-tool', params: { token: null, query: null } }, ]) }) }) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index b8d2fc4b425..863dfadc58f 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -278,6 +278,11 @@ function isEnvironmentVariableReference(value: unknown): value is string { return typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}') } +/** + * Sanitizes nested tool parameters using the same codecs as workflow search and fork remapping. + * Only parameters resolved from a registered definition retain non-sensitive values. Custom, MCP, + * and unknown schemas lack reliable secret annotations, so their generic parameters are withheld. + */ function sanitizeToolInputValue(value: unknown, options: WorkflowSanitizationOptions): unknown { const tools = parseStoredToolInputValue(value) if (!Array.isArray(value)) return null From ca48c6ffee177d71e4ad5ae4dca330a5a1e4acf9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 19:27:57 -0700 Subject: [PATCH 3/5] fix(security): redact malformed tool params --- .../credentials/credential-extractor.test.ts | 21 +++++++++++++++++++ .../credentials/credential-extractor.ts | 10 +++++++++ 2 files changed, 31 insertions(+) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts index 0362c69b7ab..8f0e7ecf9c7 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts @@ -184,4 +184,25 @@ describe('export sanitizer resource coverage', () => { { type: 'custom-tool', params: { token: null, query: null } }, ]) }) + + it.each([ + ['string', 'plaintext-secret'], + ['array', ['plaintext-secret']], + ])('withholds malformed %s tool params', (_shape, params) => { + vi.mocked(getBlock).mockReturnValue({ + name: 'Test', + description: '', + subBlocks: [{ id: 'field', title: 'Field', type: 'tool-input' }], + outputs: {}, + } as never) + + const sanitized = sanitizeWorkflowForSharing( + stateWithSubBlock('tool-input', [{ type: 'custom-tool', params }]), + { redactOpaqueCredentialInputs: true } + ) + + expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([ + { type: 'custom-tool', params: null }, + ]) + }) }) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index 863dfadc58f..48177edb576 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -1,3 +1,4 @@ +import { isPlainRecord } from '@sim/utils/object' import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry' import { setValueAtPath } from '@/lib/workflows/search-replace/value-walker' @@ -290,6 +291,15 @@ function sanitizeToolInputValue(value: unknown, options: WorkflowSanitizationOpt let sanitizedValue: unknown = value tools.forEach((tool, toolIndex) => { + const storedTool = value[toolIndex] + if (!isPlainRecord(storedTool)) { + throw new Error(`Parsed tool input at index ${toolIndex} lost its object shape`) + } + if (storedTool.params != null && !isPlainRecord(storedTool.params)) { + sanitizedValue = setValueAtPath(sanitizedValue, [toolIndex, 'params'], null) + return + } + const configs = getToolInputParamConfigs({ tool, toolIndex }) const configByParamKey = new Map< string, From 89bc93c87d40e328960abc4051393fd60c37e93d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 19:42:41 -0700 Subject: [PATCH 4/5] fix(security): redact nested credential references --- .../credentials/credential-extractor.test.ts | 35 +++++++++++++++++++ .../credentials/credential-extractor.ts | 8 ++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts index 8f0e7ecf9c7..977f9d3d20e 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts @@ -25,6 +25,7 @@ vi.mock('@/lib/workflows/search-replace/indexer', () => ({ id: paramId, type: 'short-input', password: paramId === 'apiKey' || paramId === 'token', + canonicalParamId: paramId === 'manualCredential' ? 'oauthCredential' : undefined, }, })), })) @@ -147,6 +148,40 @@ describe('export sanitizer resource coverage', () => { ]) }) + it('withholds advanced credential selectors nested inside tool inputs', () => { + const value = [ + { + type: 'gmail', + toolId: 'gmail_send', + operation: 'send_gmail', + params: { + manualCredential: 'credential-id', + query: 'safe input', + }, + }, + ] + vi.mocked(getBlock).mockReturnValue({ + name: 'Test', + description: '', + subBlocks: [{ id: 'field', title: 'Field', type: 'tool-input' }], + outputs: {}, + } as never) + + const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('tool-input', value), { + preserveEnvVars: true, + redactOpaqueCredentialInputs: true, + }) + + expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([ + { + type: 'gmail', + toolId: 'gmail_send', + operation: 'send_gmail', + params: { manualCredential: null, query: 'safe input' }, + }, + ]) + }) + it('withholds opaque table values from public snapshots', () => { const value = [ { Key: 'Authorization', Value: 'Bearer plaintext-secret' }, diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index 48177edb576..c4afe70251d 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -70,6 +70,8 @@ const WORKSPACE_SPECIFIC_TYPES: ReadonlySet = new Set([ * type-keyed registry above cannot supply, so this list stays explicit. */ const WORKSPACE_SPECIFIC_FIELDS = new Set([ + 'credentialId', + 'oauthCredential', 'knowledgeBaseId', 'tagFilters', 'documentTags', @@ -339,7 +341,11 @@ function sanitizeConfiguredSubBlockValue( if (config.password === true) { return options.preserveEnvVars && isEnvironmentVariableReference(value) ? value : null } - if (WORKSPACE_SPECIFIC_TYPES.has(config.type) || WORKSPACE_SPECIFIC_FIELDS.has(config.id)) { + if ( + WORKSPACE_SPECIFIC_TYPES.has(config.type) || + WORKSPACE_SPECIFIC_FIELDS.has(config.id) || + (config.canonicalParamId != null && WORKSPACE_SPECIFIC_FIELDS.has(config.canonicalParamId)) + ) { return null } return value From ff822805406e05388cf714b97765fac30b60b557 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 19:55:51 -0700 Subject: [PATCH 5/5] fix(security): isolate opaque tool schemas --- .../workflows/search-replace/indexer.test.ts | 17 +++++++++++ .../lib/workflows/search-replace/indexer.ts | 30 ++++++++----------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index c908b725088..8b8509107fc 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -39,6 +39,23 @@ describe('indexWorkflowSearchMatches', () => { ]) }) + it.each(['custom-tool', 'mcp'])( + 'keeps %s params non-authoritative when its tool ID collides with a built-in', + (type) => { + expect( + getToolInputParamConfigs({ + tool: { type, toolId: 'gmail_send', params: { body: 'literal-secret' } }, + }) + ).toEqual([ + expect.objectContaining({ + paramId: 'body', + authoritative: false, + value: 'literal-secret', + }), + ]) + } + ) + it('finds plain text matches across nested subblock values', () => { const workflow = createSearchReplaceWorkflowFixture() diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 960d447b8bb..2d0fdb23b7f 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -698,10 +698,10 @@ export function getToolInputParamConfigs({ credentialTypeById?: Record blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs'] }): ResolvedToolInputParamConfig[] { - const toolId = - tool.type !== 'custom-tool' && tool.type !== 'mcp' - ? getToolIdForOperation(tool.type, tool.operation) || tool.toolId - : tool.toolId + const hasAuthoritativeRegistryDefinition = tool.type !== 'custom-tool' && tool.type !== 'mcp' + const toolId = hasAuthoritativeRegistryDefinition + ? getToolIdForOperation(tool.type, tool.operation) || tool.toolId + : undefined const toolParamValues = tool.params ?? {} const values = { operation: tool.operation, ...toolParamValues } const genericFallback = () => @@ -737,20 +737,14 @@ export function getToolInputParamConfigs({ toolIndex, tool.type ) - const blockConfig = - tool.type !== 'custom-tool' && tool.type !== 'mcp' - ? (blockConfigs?.[tool.type] ?? getBlock(tool.type)) - : null - const subBlocksResult = - tool.type !== 'custom-tool' && tool.type !== 'mcp' - ? getSubBlocksForToolInput( - toolId, - tool.type, - values, - scopedCanonicalModes, - blockConfig?.subBlocks ? { subBlocks: blockConfig.subBlocks } : undefined - ) - : null + const blockConfig = blockConfigs?.[tool.type] ?? getBlock(tool.type) + const subBlocksResult = getSubBlocksForToolInput( + toolId, + tool.type, + values, + scopedCanonicalModes, + blockConfig?.subBlocks ? { subBlocks: blockConfig.subBlocks } : undefined + ) const toolParams = getToolParametersConfig(toolId, tool.type, values) const displayParams = toolParams?.userInputParameters ?? []