diff --git a/apps/sim/executor/execution/executor.test.ts b/apps/sim/executor/execution/executor.test.ts index b157952e4c3..ae4280ce553 100644 --- a/apps/sim/executor/execution/executor.test.ts +++ b/apps/sim/executor/execution/executor.test.ts @@ -429,3 +429,26 @@ describe('DAGExecutor createExecutionContext useDraftState', () => { expect(buildMetadataUseDraftState({ isDeployedContext: false })).toBe(true) }) }) + +describe('DAGExecutor executor delegation origin', () => { + it('copies the canonical origin into the runtime execution context', () => { + const executorDelegationOrigin = { + subjectUserId: 'user-1', + workflowId: 'parent-workflow', + executionId: 'parent-execution', + } + const executor = new DAGExecutor({ + workflow: { version: '1', blocks: [], connections: [] }, + contextExtensions: { executorDelegationOrigin }, + }) + + const { context } = ( + executor as unknown as { + createExecutionContext: (workflowId: string) => { context: ExecutionContext } + } + ).createExecutionContext('child-workflow') + + expect(context.workflowId).toBe('child-workflow') + expect(context.executorDelegationOrigin).toBe(executorDelegationOrigin) + }) +}) diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 6ace0229e27..e9bec84fade 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -421,6 +421,7 @@ export class DAGExecutor { fileKeys: this.contextExtensions.fileKeys, allowLargeValueWorkflowScope: this.contextExtensions.allowLargeValueWorkflowScope, userId: this.contextExtensions.userId, + executorDelegationOrigin: this.contextExtensions.executorDelegationOrigin, isDeployedContext: this.contextExtensions.isDeployedContext, enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess, piiBlockOutputRedaction: this.contextExtensions.piiBlockOutputRedaction, diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index bcfbf83210b..ff08d69c447 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -6,6 +6,7 @@ import type { NodeMetadata } from '@/executor/dag/types' import type { BlockLog, BlockState, + ExecutorDelegationOrigin, NormalizedBlockOutput, StartBlockRunMetadata, StreamingExecution, @@ -231,6 +232,8 @@ export interface ContextExtensions { fileKeys?: string[] allowLargeValueWorkflowScope?: boolean userId?: string + /** Canonical signed execution identity inherited by regular nested workflows. */ + executorDelegationOrigin?: ExecutorDelegationOrigin /** * Immutable actor/payer decision for this execution. Child workflow * executions receive it here (they carry no full metadata), so internal diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 7c13d17db5f..57a06625f9c 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -27,7 +27,10 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr import { executeProviderRequest } from '@/providers' import { installStreamingCostPolicy } from '@/providers/cost-policy' import { SIM_AUTO_MODEL_ID } from '@/providers/models' -import { getProviderToolInputProvenance } from '@/providers/tool-input-provenance' +import { + getProviderToolInputProvenance, + getProviderToolModelInputRegistry, +} from '@/providers/tool-input-provenance' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import { executeTool } from '@/tools' @@ -1155,6 +1158,61 @@ describe('AgentBlockHandler', () => { expect(runtimeContext.resolvedSecretTraceRegistry.getActiveMatches()).toEqual([]) }) + it('binds prompt-exposed placeholders to each provider tool for runtime rebinding', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'TEST_API_KEY_PERSONAL', + plaintext: 'personal-secret-value', + encryptedValue: 'encrypted-personal-secret', + }, + ]) + registry.recordResolvedAtInputPath('TEST_API_KEY_PERSONAL', 'personal-secret-value', [ + 'userPrompt', + ]) + registry.recordResolvedInputProjection( + ['userPrompt'], + 'Use personal-secret-value', + 'Use {{TEST_API_KEY_PERSONAL}}' + ) + mockContext.resolvedSecretTraceRegistry = registry + + await handler.execute(mockContext, mockBlock, { + model: 'gpt-4o', + userPrompt: 'Use personal-secret-value', + tools: [ + { + type: 'custom-tool', + title: 'canary', + schema: { + function: { + name: 'canary', + parameters: { + type: 'object', + properties: { secret: { type: 'string' } }, + required: ['secret'], + }, + }, + }, + }, + ], + }) + + const [, providerRequest] = mockExecuteProviderRequest.mock.calls[0] + const modelInputRegistry = getProviderToolModelInputRegistry(providerRequest.tools[0]) + expect(providerRequest.messages).toEqual([ + { role: 'user', content: 'Use {{TEST_API_KEY_PERSONAL}}' }, + ]) + expect( + modelInputRegistry?.resolveModelExposedEnvReferences({ + secret: '{{TEST_API_KEY_PERSONAL}}', + }) + ).toMatchObject({ + complete: true, + matched: true, + value: { secret: 'personal-secret-value' }, + }) + }) + it('does not carry a projected system prompt into Agent output provenance', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'x', encryptedValue: 'encrypted-token' }, diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index e689b411c5c..d66a080d412 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -85,6 +85,7 @@ import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models' import { type ProviderToolInputProvenance, registerProviderToolInputProvenance, + registerProviderToolModelInputRegistry, } from '@/providers/tool-input-provenance' import type { ProviderToolConfig } from '@/providers/types' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' @@ -397,6 +398,11 @@ export class AgentBlockHandler implements BlockHandler { const settledInputRegistry = ctx.resolvedSecretTraceRegistry const resultRegistry = settledInputRegistry?.forkForInputPaths([]) + if (modelInputProjection.registry) { + for (const tool of formatted.tools) { + registerProviderToolModelInputRegistry(tool, modelInputProjection.registry) + } + } if (resultRegistry && settledInputRegistry) { for (const [tool, provenance] of formatted.inputProvenance) { registerProviderToolInputProvenance(tool, { @@ -2590,6 +2596,7 @@ export class AgentBlockHandler implements BlockHandler { }, { resolvedSecretTraceRegistry: modelRuntimeRegistry, + executionContext: ctx, } ) diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts index ea5377b3984..dee8d9f70c8 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts @@ -4,7 +4,7 @@ import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' -import type { ExecutionContext } from '@/executor/types' +import type { ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types' import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { SerializedBlock } from '@/serializer/types' @@ -55,6 +55,7 @@ export function buildCustomBlockExecutionContext( options: { abortSignal?: AbortSignal resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + executorDelegationOrigin?: ExecutorDelegationOrigin } = {} ): ExecutionContext { // Prefer the invoking agent run's ids so correlation and cancellation both @@ -64,6 +65,7 @@ export function buildCustomBlockExecutionContext( workflowId: context.workflowId ?? 'custom-block-tool', workspaceId: context.workspaceId, userId: context.userId, + executorDelegationOrigin: options.executorDelegationOrigin, executionId, isDeployedContext: context.isDeployedContext, // Inherit the accumulated chain so the handler appends + validates depth; diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 7792e5430de..468f42b0ee6 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -582,6 +582,11 @@ describe('WorkflowBlockHandler', () => { expect(executorOptions[0].contextExtensions.billingAttribution).toBe(sourceAttribution) expect(executorOptions[0].contextExtensions.userId).toBe('owner-9') expect(executorOptions[0].contextExtensions.workspaceId).toBe('workspace-source') + expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({ + subjectUserId: 'owner-9', + workflowId: 'source-workflow-id', + executionId: loggingSessionArgs[0][1], + }) }) it('builds trusted caller metadata for custom block children with the toggle on', async () => { @@ -1225,6 +1230,15 @@ describe('WorkflowBlockHandler', () => { expect(loggingSessionArgs).toHaveLength(0) }) + it('fails before execution when the source child log row cannot be opened', async () => { + mockSafeStart.mockResolvedValue(false) + + await expect(handler.execute(customBlockContext(), customBlock(), {})).rejects.toThrow() + + expect(mockExecutorExecute).not.toHaveBeenCalled() + expect(executorOptions).toHaveLength(0) + }) + it('runs the child under its own execution id but keeps the parent readable', async () => { const ctx = customBlockContext() await handler.execute(ctx, customBlock(), {}) @@ -1235,6 +1249,24 @@ describe('WorkflowBlockHandler', () => { expect(ctx.largeValueExecutionIds).toContain(extensions.executionId) }) + it('replaces the consumer delegation origin with the source child execution', async () => { + const ctx = customBlockContext({ + executorDelegationOrigin: { + subjectUserId: 'consumer-1', + workflowId: 'consumer-workflow', + executionId: 'parent-execution-id', + }, + }) + + await handler.execute(ctx, customBlock(), {}) + + expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({ + subjectUserId: 'owner-9', + workflowId: 'source-workflow-id', + executionId: executorOptions[0].contextExtensions.executionId, + }) + }) + it('shares one large-value id list so nested custom blocks propagate upward', async () => { const ctx = customBlockContext() await handler.execute(ctx, customBlock(), {}) @@ -1604,9 +1636,49 @@ describe('WorkflowBlockHandler', () => { const extensions = executorOptions[0].contextExtensions expect(extensions.executionId).toBe('parent-execution-id') expect(extensions.resolvedSecretTraceRegistry).toBe(registry) + expect(extensions.executorDelegationOrigin).toEqual({ + subjectUserId: 'user-1', + workflowId: 'parent-workflow-id', + executionId: 'parent-execution-id', + }) + expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith( + extensions.executorDelegationOrigin + ) expect(extensions.onStream).toBe(ctx.onStream) expect(extensions.childWorkflowContext).toBeDefined() }) + + it('preserves the canonical parent origin through deeper regular children', async () => { + const ctx = { + ...mockContext, + workspaceId: 'workspace-1', + workflowId: 'intermediate-workflow-id', + executionId: 'parent-execution-id', + executorDelegationOrigin: { + subjectUserId: 'user-1', + workflowId: 'root-workflow-id', + executionId: 'parent-execution-id', + }, + } as ExecutionContext + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Grandchild Workflow', + workspaceId: 'workspace-1', + state: { blocks: [], edges: [], loops: {}, parallels: {} }, + }, + }), + }) + + await handler.execute(ctx, mockBlock, { workflowId: 'grandchild-workflow-id' }) + + expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith(ctx.executorDelegationOrigin) + expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toBe( + ctx.executorDelegationOrigin + ) + }) }) describe('projectCustomBlockOutput', () => { diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index e05e1188798..796aae1eb01 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -37,6 +37,7 @@ import { type BlockHandler, type ExecutionContext, type ExecutionResult, + type ExecutorDelegationOrigin, START_BLOCK_METADATA_FIELD, type StartBlockRunMetadata, type StreamingExecution, @@ -278,17 +279,22 @@ export class WorkflowBlockHandler implements BlockHandler { /** Large-value id list shared with the child (and any nested custom blocks). */ let sharedLargeValueIds: string[] | undefined let childCancellation: { signal: AbortSignal; dispose: () => void } | undefined + let childExecutorDelegationOrigin: ExecutorDelegationOrigin | undefined /** Settled in `finally` once the child is fully done — see `trackChildRun`. */ let settleChildRun: (() => void) | undefined try { if (!loadUserId) { throw new Error('Workflow child loading requires a human execution subject') } - const workflowReadHeaders = await buildExecutorDelegationHeaders({ - subjectUserId: loadUserId, - workflowId: isCustomBlock ? workflowId : ctx.workflowId, - ...(!isCustomBlock && ctx.executionId ? { executionId: ctx.executionId } : {}), - }) + const workflowReadDelegationOrigin: ExecutorDelegationOrigin = isCustomBlock + ? { subjectUserId: loadUserId, workflowId } + : (ctx.executorDelegationOrigin ?? { + subjectUserId: loadUserId, + workflowId: ctx.workflowId, + ...(ctx.executionId ? { executionId: ctx.executionId } : {}), + }) + if (!isCustomBlock) childExecutorDelegationOrigin = workflowReadDelegationOrigin + const workflowReadHeaders = await buildExecutorDelegationHeaders(workflowReadDelegationOrigin) // A custom block runs the source's latest deployment; if the source has been // undeployed there's nothing to run. `BoundarySafeError` marks the message as @@ -509,10 +515,13 @@ export class WorkflowBlockHandler implements BlockHandler { ...(correlation ? { triggerData: { correlation } } : {}), }) if (!childSessionStarted) { - logger.error('Custom block child logging failed to start; child spend will be unbilled', { - workflowId, - childExecutionId, - }) + childExecutionId = undefined + throw new Error('Custom block child logging failed to start') + } + childExecutorDelegationOrigin = { + subjectUserId: loadUserId, + workflowId, + executionId: childExecutionId, } // The child no longer shares the parent's execution id, so it no longer // hears the parent's cancellation event — bridge it explicitly. @@ -599,6 +608,7 @@ export class WorkflowBlockHandler implements BlockHandler { enforceCredentialAccess: ctx.enforceCredentialAccess, workspaceId: childWorkspaceId, userId: childUserId, + executorDelegationOrigin: childExecutorDelegationOrigin, executionId: childExecutionId ?? ctx.executionId, // Large values are cached per execution id, so a child running under its // own id still needs the invoking run's id to read values in its inputs. diff --git a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts index e8cac8e404b..5e8a7c90fc9 100644 --- a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts @@ -9,6 +9,7 @@ import { type CustomBlockExecutorContext, } from '@/executor/handlers/workflow/custom-block-tool-runner' import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' +import type { ExecutorDelegationOrigin } from '@/executor/types' import { classifyExecutionError } from '@/executor/utils/errors' import { parseJSON } from '@/executor/utils/json' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -52,6 +53,7 @@ export async function runWorkflowTool( options: { abortSignal?: AbortSignal resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + executorDelegationOrigin?: ExecutorDelegationOrigin } = {} ): Promise { if (!params.workflowId) { diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index e86e8e65e32..109d6e31e71 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -338,6 +338,19 @@ export interface BlockState { resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 } +/** + * Canonical signed execution identity used for executor-delegated internal operations. + * + * A nested workflow changes {@link ExecutionContext.workflowId} for execution semantics, but it + * still belongs to the parent log row identified here. Custom blocks replace this origin with the + * publisher-owned child execution after opening their own source-workspace log row. + */ +export interface ExecutorDelegationOrigin { + subjectUserId: string + workflowId: string + executionId?: string +} + export interface ExecutionContext { workflowId: string workspaceId?: string @@ -347,6 +360,8 @@ export interface ExecutionContext { fileKeys?: string[] allowLargeValueWorkflowScope?: boolean userId?: string + /** Trusted origin for signed executor delegation, distinct from the currently executing child. */ + executorDelegationOrigin?: ExecutorDelegationOrigin isDeployedContext?: boolean enforceCredentialAccess?: boolean copilotToolExecution?: boolean diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 8e7282b306e..24d91499987 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -185,6 +185,15 @@ export type ResolvedSecretModelEgressSnapshot = | { complete: true; matches: readonly ResolvedSecretTraceMatch[] } | { complete: false } +export type ResolvedSecretModelReferenceResolution = + | { + complete: true + matched: boolean + value: T + registry: ResolvedSecretTraceRegistry + } + | { complete: false } + export interface ResolvedSecretTraceProvenanceEntryV1 { encryptedValue: string name?: string @@ -803,6 +812,92 @@ export class ResolvedSecretTraceRegistry { return fork } + /** + * Rebinds environment placeholders that were actually projected into a model request. + * + * The model receives `{{NAME}}`, never the plaintext. A later tool argument may carry that + * placeholder back verbatim. Only named entries attached to a resolver-recorded projected input + * are eligible here, so inventing another workspace variable name does not grant access to it. + * The returned registry contains only references used by this tool call. + */ + resolveModelExposedEnvReferences(value: T): ResolvedSecretModelReferenceResolution { + if (!this.complete) return { complete: false } + + const projectedValueContains = (candidate: unknown, placeholder: string): boolean => { + const pending = [candidate] + const visited = new WeakSet() + while (pending.length > 0) { + const current = pending.pop() + if (typeof current === 'string' && current.includes(placeholder)) return true + if (current === null || typeof current !== 'object' || visited.has(current)) continue + visited.add(current) + pending.push(...Object.values(current)) + } + return false + } + + const exposedEntriesByName = new Map() + for (const state of this.resolvedInputPaths.values()) { + if (state.projectedValue === undefined) continue + for (const entryKey of state.entryKeys) { + const entry = this.activeEntries.get(entryKey) + if ( + !entry || + entry.anonymous || + entry.name.length === 0 || + !projectedValueContains(state.projectedValue, `{{${entry.name}}}`) + ) { + continue + } + const existing = exposedEntriesByName.get(entry.name) + if (existing && existing.plaintext !== entry.plaintext) return { complete: false } + exposedEntriesByName.set(entry.name, entry) + } + } + + const registry = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope) + let matched = false + const resolve = (candidate: unknown, path: string[]): unknown => { + if (typeof candidate === 'string') { + const usedEntries = new Map() + const resolved = candidate.replace(/\{\{([^{}]+)\}\}/g, (placeholder, rawName) => { + const name = String(rawName).trim() + const entry = exposedEntriesByName.get(name) + if (!entry) return placeholder + usedEntries.set(activeEntryKey(entry), entry) + return entry.plaintext + }) + if (usedEntries.size === 0) return candidate + + matched = true + for (const entry of usedEntries.values()) { + registry.addActiveEntry({ ...entry }, { propagated: true }) + } + registry.bindResolvedInputPathEntries(path, usedEntries.values()) + registry.recordResolvedInputProjection(path, resolved, candidate) + return resolved + } + if (Array.isArray(candidate)) { + return candidate.map((item, index) => resolve(item, [...path, String(index)])) + } + if (candidate === null || typeof candidate !== 'object') return candidate + + const resolved: Record = {} + for (const [key, child] of Object.entries(candidate)) { + resolved[key] = resolve(child, [...path, key]) + } + return resolved + } + + const resolvedValue = resolve(value, []) as T + return { + complete: true, + matched, + value: resolvedValue, + registry, + } + } + /** Merges one settled tool-call registry into the turn-scoped registry. */ mergeToolCallRegistry(child: ResolvedSecretTraceRegistry): void { if (!scopesMatch(this.scope, child.scope)) { diff --git a/apps/sim/lib/workflows/application/authorization.test.ts b/apps/sim/lib/workflows/application/authorization.test.ts new file mode 100644 index 00000000000..2ee430dca05 --- /dev/null +++ b/apps/sim/lib/workflows/application/authorization.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ + +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { describe, expect, it } from 'vitest' +import { + WORKFLOW_DELEGATION_AUDIENCE, + workflowDelegationPolicy, +} from '@/lib/workflows/application/authorization' +import { workflowOperations } from '@/lib/workflows/application/operations' + +function createExecutorPrincipal(overrides: Partial = {}): DelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'execution-1', + audience: WORKFLOW_DELEGATION_AUDIENCE, + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { kind: 'workflow_execution', workflowId: 'parent-workflow' }, + ...overrides, + } +} + +describe('workflow delegation policy', () => { + it('allows an active execution to read a different workflow in the same workspace', () => { + const principal = createExecutorPrincipal() + + expect( + workflowDelegationPolicy.isWithinScope(principal, { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + workflowId: 'child-workflow', + }) + ).toBe(true) + }) + + it('rejects a child workflow in another workspace', () => { + const principal = createExecutorPrincipal() + + expect( + workflowDelegationPolicy.isWithinScope(principal, { + workspaceId: 'workspace-2', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-2', + workflowId: 'child-workflow', + }) + ).toBe(false) + }) + + it('rejects executor delegation without a canonical workflow execution origin', () => { + const principal = createExecutorPrincipal({ delegationContext: undefined }) + + expect( + workflowDelegationPolicy.isWithinScope(principal, { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + workflowId: 'child-workflow', + }) + ).toBe(false) + }) + + it('permits executor delegation only on workflow reads', () => { + expect(workflowOperations.read.delegatedServices).toContain('executor') + expect(workflowOperations.update.delegatedServices).not.toContain('executor') + expect(workflowOperations.delete.delegatedServices).not.toContain('executor') + }) +}) diff --git a/apps/sim/lib/workflows/application/authorization.ts b/apps/sim/lib/workflows/application/authorization.ts index 9fc44c899e3..87e574fb6c9 100644 --- a/apps/sim/lib/workflows/application/authorization.ts +++ b/apps/sim/lib/workflows/application/authorization.ts @@ -29,8 +29,7 @@ export const workflowDelegationPolicy: WorkspaceDelegationPolicy 0 && - context.workflowId === delegationContext.workflowId + delegationContext.workflowId.length > 0 ) }, } diff --git a/apps/sim/providers/runtime-context.test.ts b/apps/sim/providers/runtime-context.test.ts index 539bd3580a9..74ab0d7ee1e 100644 --- a/apps/sim/providers/runtime-context.test.ts +++ b/apps/sim/providers/runtime-context.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { createExecutionContext } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockExecuteTool } = vi.hoisted(() => ({ @@ -17,7 +18,10 @@ import { executeProviderTool as executeProviderToolWithInput, runWithProviderRuntimeContext, } from '@/providers/runtime-context' -import { registerProviderToolInputProvenance } from '@/providers/tool-input-provenance' +import { + registerProviderToolInputProvenance, + registerProviderToolModelInputRegistry, +} from '@/providers/tool-input-provenance' import { prepareToolExecution } from '@/providers/utils' async function executeProviderTool( @@ -85,6 +89,92 @@ describe('provider runtime context', () => { expect(toolCall?.[2]?.resolvedSecretTraceRegistry).not.toBe(registry) }) + it('passes the trusted execution context to provider-emitted tool calls', async () => { + const registry = new ResolvedSecretTraceRegistry() + const executionContext = createExecutionContext({ + workflowId: 'workflow-parent', + environmentVariables: {}, + }) + + await runWithProviderRuntimeContext( + { executionContext, resolvedSecretTraceRegistry: registry }, + () => executeProviderTool('protected-tool', {}) + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'protected-tool', + {}, + expect.objectContaining({ executionContext }) + ) + }) + + it('rebinds a prompt-exposed environment placeholder for the exact tool call', async () => { + const sourceRegistry = new ResolvedSecretTraceRegistry([ + { + name: 'TEST_API_KEY_PERSONAL', + plaintext: 'personal-secret-value', + encryptedValue: 'encrypted-personal-secret', + }, + ]) + sourceRegistry.recordResolvedAtInputPath('TEST_API_KEY_PERSONAL', 'personal-secret-value', [ + 'userPrompt', + ]) + sourceRegistry.recordResolvedInputProjection( + ['userPrompt'], + 'Use personal-secret-value', + 'Use {{TEST_API_KEY_PERSONAL}}' + ) + const modelInputRegistry = sourceRegistry.forkForInputPaths([['userPrompt']]) + const runtimeRegistry = sourceRegistry.forkForInputPaths([]) + const tool = { + id: 'custom_canary', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } + registerProviderToolModelInputRegistry(tool, modelInputRegistry) + mockExecuteTool.mockResolvedValueOnce({ + success: true, + output: { reflected: 'personal-secret-value' }, + }) + + const execution = await runWithProviderRuntimeContext( + { resolvedSecretTraceRegistry: runtimeRegistry }, + () => { + const { executionParams } = prepareToolExecution( + tool, + { secret: '{{TEST_API_KEY_PERSONAL}}' }, + {} + ) + return executeProviderToolWithInput(tool.id, executionParams) + } + ) + + expect(mockExecuteTool.mock.calls.at(-1)?.[1]).toEqual({ + secret: 'personal-secret-value', + _toolSchema: { type: 'object', properties: {}, required: [] }, + }) + expect(execution.rawResponse.output).toEqual({ reflected: 'personal-secret-value' }) + expect(execution.modelResponse.output).toEqual({ + reflected: '{{TEST_API_KEY_PERSONAL}}', + }) + }) + + it('does not bind an environment placeholder absent from the model input', async () => { + const sourceRegistry = new ResolvedSecretTraceRegistry([ + { name: 'UNEXPOSED', plaintext: 'hidden-value', encryptedValue: 'encrypted-hidden' }, + ]) + const tool = { + id: 'custom_canary', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } + registerProviderToolModelInputRegistry(tool, sourceRegistry) + + const { executionParams } = prepareToolExecution(tool, { secret: '{{UNEXPOSED}}' }, {}) + + expect(executionParams.secret).toBe('{{UNEXPOSED}}') + }) + it('does not treat an arbitrary tool-result collision as secret provenance', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' }, diff --git a/apps/sim/providers/runtime-context.ts b/apps/sim/providers/runtime-context.ts index 1060c18011d..8f9f7e19d1a 100644 --- a/apps/sim/providers/runtime-context.ts +++ b/apps/sim/providers/runtime-context.ts @@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { getErrorMessage } from '@sim/utils/errors' import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import type { ExecutionContext } from '@/executor/types' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { getPreparedProviderToolInputProvenance } from '@/providers/tool-input-provenance' import { type ExecuteToolOptions, executeTool } from '@/tools' @@ -9,6 +10,8 @@ import type { ToolResponse } from '@/tools/types' export interface ProviderRuntimeContext { resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + /** Trusted server execution context inherited by model-emitted tool calls. */ + executionContext?: ExecutionContext } export type ExecuteProviderToolOptions = ExecuteToolOptions @@ -68,8 +71,10 @@ export async function executeProviderTool( : undefined try { + const executionContext = options.executionContext ?? runtimeContext?.executionContext const result = await executeTool(toolId, params, { ...options, + ...(executionContext ? { executionContext } : {}), resolvedSecretTraceRegistry: toolCallRegistry, }) if (!registry || !toolCallRegistry) { diff --git a/apps/sim/providers/tool-input-provenance.ts b/apps/sim/providers/tool-input-provenance.ts index 956b910b4e2..55e3a24f4fa 100644 --- a/apps/sim/providers/tool-input-provenance.ts +++ b/apps/sim/providers/tool-input-provenance.ts @@ -16,6 +16,7 @@ export interface PreparedProviderToolInputProvenance { const configuredToolInputProvenance = new WeakMap() const preparedToolInputProvenance = new WeakMap() +const modelInputRegistries = new WeakMap() /** Associates one provider tool object with its exact resolver-recorded preset input. */ export function registerProviderToolInputProvenance( @@ -32,6 +33,21 @@ export function getProviderToolInputProvenance( return configuredToolInputProvenance.get(tool) } +/** Associates a provider tool with secrets whose placeholders were visible in this model turn. */ +export function registerProviderToolModelInputRegistry( + tool: object, + registry: ResolvedSecretTraceRegistry +): void { + modelInputRegistries.set(tool, registry) +} + +/** Reads the model-visible placeholder allowlist for the exact provider tool instance. */ +export function getProviderToolModelInputRegistry( + tool: object +): ResolvedSecretTraceRegistry | undefined { + return modelInputRegistries.get(tool) +} + /** Associates one prepared execution object with its isolated transformed-input registry. */ export function registerPreparedProviderToolInputProvenance( executionParams: object, diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index c9447a75fd3..39cced83ab5 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -52,6 +52,7 @@ import { } from '@/providers/models' import { getProviderToolInputProvenance, + getProviderToolModelInputRegistry, registerPreparedProviderToolInputProvenance, } from '@/providers/tool-input-provenance' import type { ProviderId, ProviderToolConfig } from '@/providers/types' @@ -1563,11 +1564,27 @@ export function prepareToolExecution( // scoped to "Selected secrets" with an empty list is an explicit deny, and a // model emitting `mountedSecrets: ['STRIPE_KEY']` would otherwise mount it. const modelParams = stripModelBlockedParams(tool.modelBlockedParams, llmArgs) - let toolParams = mergeToolParameters(tool.params || {}, modelParams) as Record + const modelInputRegistry = getProviderToolModelInputRegistry(tool) + const modelReferenceResolution = modelInputRegistry?.resolveModelExposedEnvReferences(modelParams) + if (modelReferenceResolution && !modelReferenceResolution.complete) { + throw new Error('Agent tool input environment references could not be safely resolved') + } + const resolvedModelParams = modelReferenceResolution?.value ?? modelParams + let toolParams = mergeToolParameters(tool.params || {}, resolvedModelParams) const inputProvenance = getProviderToolInputProvenance(tool) - const inputRegistry = inputProvenance?.registry.forkForInputPaths([inputProvenance.sourcePath]) - let projectedToolParams = inputProvenance - ? (mergeToolParameters(inputProvenance.projectedParams, modelParams) as Record) + let inputRegistry = inputProvenance?.registry.forkForInputPaths([inputProvenance.sourcePath]) + if (modelReferenceResolution?.matched) { + if (inputRegistry) { + inputRegistry.mergeToolCallRegistry(modelReferenceResolution.registry) + } else { + inputRegistry = modelReferenceResolution.registry + } + } + if (inputRegistry && !inputRegistry.isComplete()) { + throw new Error('Agent tool input environment references could not be safely resolved') + } + let projectedToolParams = inputRegistry + ? mergeToolParameters(inputProvenance?.projectedParams ?? tool.params ?? {}, modelParams) : undefined if (tool.paramsTransform) { @@ -1622,7 +1639,7 @@ export function prepareToolExecution( ...(tool.parameters ? { _toolSchema: tool.parameters } : {}), } - if (inputProvenance && inputRegistry) { + if (inputRegistry) { const inputPaths = [['params']] as const if (projectedToolParams) { inputRegistry.recordTransformedInputProjection( diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 7d6d915a3b5..40429d0803e 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -37,6 +37,7 @@ import { import { fileGetContentTool } from '@/tools/file/get' import { memoryAddTool } from '@/tools/memory/add' import { tableBatchInsertRowsTool } from '@/tools/table/batch_insert_rows' +import { customBlockExecutorTool } from '@/tools/workflow/custom-block-executor' import { workflowExecutorTool } from '@/tools/workflow/executor' // Hoisted mock state - these are available to vi.mock factories @@ -47,6 +48,7 @@ const { mockGetCustomToolById, mockListCustomTools, mockMarkWorkspaceFileSecretProvenanceUnknown, + mockRunCustomBlockTool, mockRunWorkflowTool, mockGetCustomToolByIdOrTitle, mockGenerateInternalDelegationToken, @@ -63,6 +65,7 @@ const { mockGetCustomToolById: vi.fn(), mockListCustomTools: vi.fn(), mockMarkWorkspaceFileSecretProvenanceUnknown: vi.fn(), + mockRunCustomBlockTool: vi.fn(), mockRunWorkflowTool: vi.fn(), mockGetCustomToolByIdOrTitle: vi.fn(), mockGenerateInternalDelegationToken: vi.fn(), @@ -130,9 +133,14 @@ vi.mock('@/executor/handlers/workflow/workflow-tool-runner', () => ({ runWorkflowTool: (...args: unknown[]) => mockRunWorkflowTool(...args), })) +vi.mock('@/executor/handlers/workflow/custom-block-tool-runner', () => ({ + runCustomBlockTool: (...args: unknown[]) => mockRunCustomBlockTool(...args), +})) + // Mock the tools registry to avoid loading the full 4500+ line registry file. // Only the tools actually exercised in tests are provided. const mockRegistryTools: Record = { + deployed_block_executor: customBlockExecutorTool, workflow_executor: workflowExecutorTool, file_get_content: fileGetContentTool, memory_add: memoryAddTool, @@ -774,6 +782,36 @@ describe('executeTool Function', () => { expect(new Headers(request?.headers).get('authorization')).toBe('Bearer executor-token') }) + it('uses the canonical parent origin for protected tools inside a nested workflow', async () => { + global.fetch = Object.assign( + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const executionContext = createToolExecutionContext({ + userId: 'trusted-user', + workflowId: 'child-workflow', + executionId: 'parent-execution', + executorDelegationOrigin: { + subjectUserId: 'trusted-user', + workflowId: 'parent-workflow', + executionId: 'parent-execution', + }, + }) + await executeTool('test_executor_delegation', {}, { executionContext }) + + expect(mockGenerateInternalDelegationToken).toHaveBeenCalledWith({ + subjectUserId: 'trusted-user', + workflowId: 'parent-workflow', + executionId: 'parent-execution', + }) + }) + it('rejects protected internal tools without trusted executor scope before transport', async () => { const result = await executeTool('test_executor_delegation', { _context: { @@ -1481,6 +1519,55 @@ describe('executeTool Function', () => { expect(global.fetch).not.toHaveBeenCalled() }) + it('overwrites custom-block tool context with the trusted workflow scope', async () => { + const executionContext = createToolExecutionContext({ + userId: 'trusted-user', + workflowId: 'trusted-workflow', + workspaceId: 'trusted-workspace', + executionId: 'trusted-execution', + callChain: ['trusted-parent'], + isDeployedContext: true, + }) + mockRunCustomBlockTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + + await executeTool( + 'deployed_block_executor_custom_block_123', + { + blockType: 'custom_block_123', + _context: { + userId: 'forged-user', + workflowId: 'forged-workflow', + workspaceId: 'forged-workspace', + executionId: 'forged-execution', + callChain: ['forged-parent'], + isDeployedContext: false, + billingAttribution: { forged: true }, + }, + }, + { executionContext } + ) + + expect(mockRunCustomBlockTool).toHaveBeenCalledWith( + expect.objectContaining({ + blockType: 'custom_block_123', + _context: expect.objectContaining({ + userId: 'trusted-user', + workflowId: 'trusted-workflow', + workspaceId: 'trusted-workspace', + executionId: 'trusted-execution', + callChain: ['trusted-parent'], + isDeployedContext: true, + billingAttribution: TEST_BILLING_ATTRIBUTION, + requestId: expect.any(String), + }), + }), + expect.objectContaining({ + resolvedSecretTraceRegistry: undefined, + }) + ) + expect(global.fetch).not.toHaveBeenCalled() + }) + it('filters cross-scope workflow provenance to literals present in the unchanged result', async () => { const registry = new ResolvedSecretTraceRegistry([], { userId: 'parent-user', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index e62e10c6464..26e63537fb4 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -158,6 +158,13 @@ function resolveInternalExecutorDelegation( if (!executionContext?.userId || !executionContext.workflowId) { throw new Error('Executor delegation requires a trusted workflow execution context') } + if (executionContext.executorDelegationOrigin) { + const origin = executionContext.executorDelegationOrigin + if (!origin.subjectUserId || !origin.workflowId) { + throw new Error('Executor delegation origin requires an authenticated user and workflow') + } + return origin + } return { subjectUserId: executionContext.userId, workflowId: executionContext.workflowId, @@ -1780,6 +1787,7 @@ async function executeToolImplementation( { abortSignal: effectiveSignal, resolvedSecretTraceRegistry, + executorDelegationOrigin: executionContext?.executorDelegationOrigin, } ) const endTime = new Date() @@ -1807,7 +1815,15 @@ async function executeToolImplementation( ...contextParams, _context: { ...(contextParams._context as Record | undefined), + ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), + ...(scope.workflowId ? { workflowId: scope.workflowId } : {}), + ...(scope.userId ? { userId: scope.userId } : {}), ...(scope.executionId ? { executionId: scope.executionId } : {}), + ...(scope.callChain ? { callChain: scope.callChain } : {}), + ...(scope.isDeployedContext !== undefined + ? { isDeployedContext: scope.isDeployedContext } + : {}), + ...(scope.billingAttribution ? { billingAttribution: scope.billingAttribution } : {}), requestId, }, },