mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(security): redact workflow snapshot secrets (#6581)
* fix(security): redact opaque workflow snapshot inputs * fix(security): document fail-closed tool redaction * fix(security): redact malformed tool params * fix(security): redact nested credential references * fix(security): isolate opaque tool schemas
This commit is contained in:
@@ -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 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",
|
||||
|
||||
@@ -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; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.",
|
||||
"$ref": "#/components/schemas/DeployedWorkflowState"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -29,6 +29,23 @@ 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: { type: string; params?: Record<string, unknown> }
|
||||
}) =>
|
||||
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',
|
||||
},
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getBlock: () => ({
|
||||
name: 'Slack',
|
||||
@@ -36,6 +53,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 +107,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 +183,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: null },
|
||||
},
|
||||
])
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 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({
|
||||
|
||||
@@ -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; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.'
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -43,6 +43,24 @@ vi.mock('@/lib/logs/execution/trace-store', () => ({
|
||||
materializeExecutionDataForDisplay: mocks.materialize,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/search-replace/indexer', () => ({
|
||||
getToolInputParamConfigs: ({
|
||||
tool,
|
||||
}: {
|
||||
tool: { type: string; params?: Record<string, unknown> }
|
||||
}) =>
|
||||
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',
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit }))
|
||||
|
||||
/**
|
||||
@@ -56,6 +74,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 +175,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 +212,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: null },
|
||||
},
|
||||
])
|
||||
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 () => {
|
||||
|
||||
@@ -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
|
||||
@@ -19,5 +20,8 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
*/
|
||||
export function sanitizeExecutionSnapshotState(state: unknown): Record<string, unknown> | null {
|
||||
if (typeof state !== 'object' || state === null) return null
|
||||
return sanitizeWorkflowForSharing(state as Partial<WorkflowState>, { preserveEnvVars: true })
|
||||
return sanitizeWorkflowForSharing(state as Partial<WorkflowState>, {
|
||||
preserveEnvVars: true,
|
||||
redactOpaqueCredentialInputs: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,10 +20,15 @@ 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, sensitive nested tool parameters, and tool parameters without authoritative codec
|
||||
* metadata 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
|
||||
}
|
||||
|
||||
@@ -5,11 +5,31 @@ 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: { type: string; params?: Record<string, unknown> }
|
||||
}) =>
|
||||
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',
|
||||
canonicalParamId: paramId === 'manualCredential' ? 'oauthCredential' : undefined,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
function stateWithSubBlock(type: string, value: unknown): Partial<WorkflowState> {
|
||||
return {
|
||||
blocks: {
|
||||
@@ -93,4 +113,131 @@ describe('export sanitizer resource coverage', () => {
|
||||
} as unknown as Partial<WorkflowState>)
|
||||
expect(sanitized.blocks?.b1?.subBlocks?.tableId?.value).toBeNull()
|
||||
})
|
||||
|
||||
it('uses authoritative tool-input codecs to withhold secrets while preserving safe config', () => {
|
||||
const value = [
|
||||
{
|
||||
type: 'gmail',
|
||||
toolId: 'gmail_send',
|
||||
operation: 'send_gmail',
|
||||
params: {
|
||||
apiKey: 'sk-plaintext-secret',
|
||||
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: { apiKey: null, query: 'safe input' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
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' },
|
||||
{ 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 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', query: 'ordinary configuration' },
|
||||
},
|
||||
]),
|
||||
{ redactOpaqueCredentialInputs: true }
|
||||
)
|
||||
|
||||
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
|
||||
{ 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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
buildSubBlockValues,
|
||||
@@ -8,6 +11,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'
|
||||
@@ -66,6 +70,8 @@ const WORKSPACE_SPECIFIC_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
* type-keyed registry above cannot supply, so this list stays explicit.
|
||||
*/
|
||||
const WORKSPACE_SPECIFIC_FIELDS = new Set([
|
||||
'credentialId',
|
||||
'oauthCredential',
|
||||
'knowledgeBaseId',
|
||||
'tagFilters',
|
||||
'documentTags',
|
||||
@@ -77,6 +83,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<string> = new Set(['table'])
|
||||
|
||||
/**
|
||||
* Extract required credentials from a workflow state
|
||||
* This analyzes all blocks and their subblocks to identify credential requirements
|
||||
@@ -192,9 +207,13 @@ function formatFieldName(fieldName: string): string {
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
interface MutableSubBlockState extends Omit<SubBlockState, 'value'> {
|
||||
value: unknown
|
||||
}
|
||||
|
||||
/** Block state with mutable subBlocks for sanitization */
|
||||
interface MutableBlockState extends Omit<BlockState, 'subBlocks'> {
|
||||
subBlocks: Record<string, SubBlockState | null | undefined>
|
||||
subBlocks: Record<string, MutableSubBlockState | null | undefined>
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -248,6 +267,90 @@ 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('}}')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
if (tools.length !== value.length) return null
|
||||
|
||||
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,
|
||||
{ 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) ||
|
||||
(config.canonicalParamId != null && WORKSPACE_SPECIFIC_FIELDS.has(config.canonicalParamId))
|
||||
) {
|
||||
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 +360,7 @@ interface SanitizedWorkflowState {
|
||||
*/
|
||||
export function sanitizeWorkflowForSharing(
|
||||
state: Partial<WorkflowState> | null | undefined,
|
||||
options: {
|
||||
preserveEnvVars?: boolean // Keep {{VAR}} references for export
|
||||
} = {}
|
||||
options: WorkflowSanitizationOptions = {}
|
||||
): SanitizedWorkflowState {
|
||||
const sanitized = structuredClone(state) as SanitizedWorkflowState
|
||||
|
||||
@@ -281,35 +382,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 +394,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
|
||||
|
||||
@@ -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,37 @@ 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.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()
|
||||
|
||||
|
||||
@@ -668,6 +668,16 @@ function isVisibleToolParameter(param: ToolParameterConfig, values: Record<strin
|
||||
)
|
||||
}
|
||||
|
||||
export interface ResolvedToolInputParamConfig {
|
||||
paramId: string
|
||||
config: WorkflowSearchSubBlockConfig
|
||||
value: unknown
|
||||
/** False when the codec had no registered tool definition and inferred only a generic shape. */
|
||||
authoritative: boolean
|
||||
selectorContext?: SelectorContext
|
||||
dependentValuePaths?: WorkflowSearchValuePath[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stored tool's params to their subBlock configs (the same resolution
|
||||
* the search index + UI use). Exported so cross-workspace remapping (fork/promote)
|
||||
@@ -687,17 +697,11 @@ export function getToolInputParamConfigs({
|
||||
parentCanonicalModes?: CanonicalModeOverrides
|
||||
credentialTypeById?: Record<string, string | undefined>
|
||||
blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs']
|
||||
}): Array<{
|
||||
paramId: string
|
||||
config: WorkflowSearchSubBlockConfig
|
||||
value: unknown
|
||||
selectorContext?: SelectorContext
|
||||
dependentValuePaths?: WorkflowSearchValuePath[]
|
||||
}> {
|
||||
const toolId =
|
||||
tool.type !== 'custom-tool' && tool.type !== 'mcp'
|
||||
? getToolIdForOperation(tool.type, tool.operation) || tool.toolId
|
||||
: tool.toolId
|
||||
}): ResolvedToolInputParamConfig[] {
|
||||
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 = () =>
|
||||
@@ -715,6 +719,7 @@ export function getToolInputParamConfigs({
|
||||
const type = getFallbackToolParamType(value)
|
||||
return {
|
||||
paramId,
|
||||
authoritative: false,
|
||||
config: {
|
||||
id: paramId,
|
||||
title: paramId,
|
||||
@@ -732,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 ?? []
|
||||
|
||||
@@ -759,6 +758,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 +811,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 +831,7 @@ export function getToolInputParamConfigs({
|
||||
const config = buildToolInputSearchConfig(param)
|
||||
return {
|
||||
paramId: param.id,
|
||||
authoritative: true,
|
||||
config,
|
||||
value: parseToolParamValue(toolParamValues[param.id], config.type),
|
||||
selectorContext:
|
||||
|
||||
Reference in New Issue
Block a user