diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts index 5254a2ad607..b8a7e26a32b 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts @@ -15,12 +15,19 @@ vi.mock('@/executor/handlers/workflow/workflow-handler', () => ({ })) import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' +import type { PiiBlockOutputRedaction } from '@/executor/execution/types' import { buildCustomBlockExecutionContext, runCustomBlockTool, } from '@/executor/handlers/workflow/custom-block-tool-runner' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +const PII_POLICY: PiiBlockOutputRedaction = { + enabled: true, + entityTypes: ['EMAIL_ADDRESS'], + language: 'en', +} + const mockRunnerLogger = vi.mocked(createLogger).mock.results[ vi.mocked(createLogger).mock.calls.findIndex(([name]) => name === 'CustomBlockToolRunner') @@ -28,13 +35,16 @@ const mockRunnerLogger = describe('buildCustomBlockExecutionContext', () => { it('carries consumer identity, inherits the call chain, and is fully scaffolded', () => { - const ctx = buildCustomBlockExecutionContext({ - workspaceId: 'ws-consumer', - userId: 'u-consumer', - workflowId: 'wf-parent', - callChain: ['wf-parent'], - billingAttribution: { actorUserId: 'u-consumer', workspaceId: 'ws-consumer' } as any, - }) + const ctx = buildCustomBlockExecutionContext( + { + workspaceId: 'ws-consumer', + userId: 'u-consumer', + workflowId: 'wf-parent', + callChain: ['wf-parent'], + billingAttribution: { actorUserId: 'u-consumer', workspaceId: 'ws-consumer' } as any, + }, + { environmentVariables: {} } + ) expect(ctx.workspaceId).toBe('ws-consumer') expect(ctx.userId).toBe('u-consumer') @@ -59,7 +69,20 @@ describe('buildCustomBlockExecutionContext', () => { }) it('defaults the call chain to [] when none is provided', () => { - expect(buildCustomBlockExecutionContext({}).callChain).toEqual([]) + expect(buildCustomBlockExecutionContext({}, { environmentVariables: {} }).callChain).toEqual([]) + }) + + it('carries the caller-supplied env map and redaction policy verbatim', () => { + const ctx = buildCustomBlockExecutionContext( + { workspaceId: 'ws-1' }, + { + environmentVariables: { MY_API_KEY: 'secret-value' }, + piiBlockOutputRedaction: PII_POLICY, + } + ) + + expect(ctx.environmentVariables).toEqual({ MY_API_KEY: 'secret-value' }) + expect(ctx.piiBlockOutputRedaction).toBe(PII_POLICY) }) }) @@ -135,6 +158,16 @@ describe('runCustomBlockTool', () => { expect(res.output).toEqual({}) }) + it('runs the child with no env and no redaction policy — the custom branch re-derives both', async () => { + mockExecute.mockResolvedValue({ success: true }) + + await runCustomBlockTool({ blockType: 'custom_block_abc', _context: {} }) + + const [ctxArg] = mockExecute.mock.calls[0] + expect(ctxArg.environmentVariables).toEqual({}) + expect(ctxArg.piiBlockOutputRedaction).toBeUndefined() + }) + it('rejects a missing block type without invoking the handler', async () => { const res = await runCustomBlockTool({ _context: {} }) expect(res.success).toBe(false) @@ -144,11 +177,14 @@ describe('runCustomBlockTool', () => { describe('buildCustomBlockExecutionContext invoker identity', () => { it("adopts the invoking run's ids so correlation names a real execution", () => { - const ctx = buildCustomBlockExecutionContext({ - workspaceId: 'ws-1', - executionId: 'agent-execution-id', - requestId: 'agent-request-id', - }) + const ctx = buildCustomBlockExecutionContext( + { + workspaceId: 'ws-1', + executionId: 'agent-execution-id', + requestId: 'agent-request-id', + }, + { environmentVariables: {} } + ) expect(ctx.executionId).toBe('agent-execution-id') expect(ctx.metadata.executionId).toBe('agent-execution-id') @@ -156,7 +192,10 @@ describe('buildCustomBlockExecutionContext invoker identity', () => { }) it('falls back to generated ids when the caller supplies none', () => { - const ctx = buildCustomBlockExecutionContext({ workspaceId: 'ws-1' }) + const ctx = buildCustomBlockExecutionContext( + { workspaceId: 'ws-1' }, + { environmentVariables: {} } + ) expect(ctx.executionId).toBeTruthy() expect(ctx.metadata.requestId).toBeTruthy() @@ -169,14 +208,17 @@ describe('buildCustomBlockExecutionContext cancellation', () => { const controller = new AbortController() const ctx = buildCustomBlockExecutionContext( { workspaceId: 'ws-1' }, - { abortSignal: controller.signal } + { environmentVariables: {}, abortSignal: controller.signal } ) expect(ctx.abortSignal).toBe(controller.signal) }) it('leaves the signal undefined when the caller has none', () => { - expect(buildCustomBlockExecutionContext({ workspaceId: 'ws-1' }).abortSignal).toBeUndefined() + expect( + buildCustomBlockExecutionContext({ workspaceId: 'ws-1' }, { environmentVariables: {} }) + .abortSignal + ).toBeUndefined() }) }) @@ -186,7 +228,7 @@ describe('buildCustomBlockExecutionContext secret provenance', () => { const ctx = buildCustomBlockExecutionContext( { workspaceId: 'ws-1' }, - { resolvedSecretTraceRegistry: registry } + { environmentVariables: {}, resolvedSecretTraceRegistry: registry } ) expect(ctx.resolvedSecretTraceRegistry).toBe(registry) 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 dee8d9f70c8..c1dd651be1a 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts @@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import type { PiiBlockOutputRedaction } from '@/executor/execution/types' import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' import type { ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types' import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' @@ -40,23 +41,40 @@ interface CustomBlockToolParams { } /** - * Build a minimal top-level `ExecutionContext` for running a custom block as an - * agent tool. Every value comes from the server-set `_context` (LLM-proof), - * including the invoking run's execution and request ids so the child's log - * correlation names a real execution. `WorkflowBlockHandler`'s path re-derives owner - * identity, env, and billing from `getCustomBlockAuthority`, so this only needs the - * fields that path reads — `workspaceId` (org-scopes the authority lookup), - * `metadata` (read unconditionally at `executeCore`), and `callChain` (recursion - * depth guard, inherited so it never resets across hops) — plus the non-optional - * scaffolding. Keep in sync with `WorkflowBlockHandler.executeCore`'s custom branch. + * Build a minimal top-level `ExecutionContext` for running a workflow or a custom + * block as an agent tool. Every value comes from the server-set `_context` + * (LLM-proof) or from `options` (not model-reachable at all), including the + * invoking run's execution and request ids so the child's log correlation names a + * real execution. `WorkflowBlockHandler.executeCore` reads `workspaceId` (org-scopes + * the authority lookup), `metadata` (read unconditionally), and `callChain` + * (recursion depth guard, inherited so it never resets across hops), plus the + * non-optional scaffolding. + * + * `environmentVariables` is required rather than defaulted because only the caller + * knows which identity's env the child must run under: the custom-block branch + * re-derives the publisher's env from `getCustomBlockAuthority` and passes `{}`, + * while every other caller must forward the invoking run's map or the child + * resolves `{{VAR}}` to the literal reference string. Silent omission is exactly + * how the workflow-as-agent-tool path shipped with an empty map. + * + * `piiBlockOutputRedaction` stays optional because `undefined` is its correct + * value rather than a wrong identity: most tenants have no policy at all, and the + * custom-block branch omits it deliberately — that child runs cross-workspace + * under the publisher's identity, so the consumer's redaction rules would be the + * wrong tenant's, exactly as the consumer's env would be. + * Keep in sync with `WorkflowBlockHandler.executeCore`. */ export function buildCustomBlockExecutionContext( context: CustomBlockExecutorContext, options: { + /** The invoking run's decrypted env, or `{}` when the child re-derives its own. */ + environmentVariables: Record abortSignal?: AbortSignal resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry executorDelegationOrigin?: ExecutorDelegationOrigin - } = {} + /** The invoking run's in-flight block-output redaction policy. */ + piiBlockOutputRedaction?: PiiBlockOutputRedaction + } ): ExecutionContext { // Prefer the invoking agent run's ids so correlation and cancellation both // point at a real execution; fall back only when a caller could not supply them. @@ -75,7 +93,8 @@ export function buildCustomBlockExecutionContext( // the agent tool loop owns the only signal reaching this path. abortSignal: options.abortSignal, resolvedSecretTraceRegistry: options.resolvedSecretTraceRegistry, - environmentVariables: {}, + environmentVariables: options.environmentVariables, + piiBlockOutputRedaction: options.piiBlockOutputRedaction, blockStates: new Map(), executedBlocks: new Set(), blockLogs: [], @@ -121,6 +140,7 @@ export async function runCustomBlockTool( } const ctx = buildCustomBlockExecutionContext(params._context ?? {}, { + environmentVariables: {}, abortSignal: options.abortSignal, resolvedSecretTraceRegistry: options.resolvedSecretTraceRegistry, }) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 468f42b0ee6..96d0586dad6 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -516,6 +516,43 @@ describe('WorkflowBlockHandler', () => { expect(mockResolveBillingAttribution).not.toHaveBeenCalled() }) + it("runs a non-custom child under the parent's env and redaction policy", async () => { + const piiBlockOutputRedaction = { + enabled: true, + entityTypes: ['EMAIL_ADDRESS'], + language: 'en', + } + const ctx = { + ...mockContext, + workspaceId: 'workspace-parent', + environmentVariables: { MY_API_KEY: 'parent-secret' }, + piiBlockOutputRedaction, + } as unknown as ExecutionContext + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + workspaceId: 'workspace-parent', + state: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }, + }), + }) + mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) + mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) + + await handler.execute(ctx, mockBlock, inputs) + + expect(executorOptions).toHaveLength(1) + expect(executorOptions[0].envVarValues).toEqual({ MY_API_KEY: 'parent-secret' }) + expect(executorOptions[0].contextExtensions.piiBlockOutputRedaction).toBe( + piiBlockOutputRedaction + ) + expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled() + }) + it('resolves a source-scoped billing attribution for custom block children', async () => { const consumerAttribution = { actorUserId: 'consumer-1', workspaceId: 'workspace-consumer' } const sourceAttribution = { actorUserId: 'owner-9', workspaceId: 'workspace-source' } diff --git a/apps/sim/executor/handlers/workflow/workflow-tool-runner.test.ts b/apps/sim/executor/handlers/workflow/workflow-tool-runner.test.ts new file mode 100644 index 00000000000..1a3bd3f54d8 --- /dev/null +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() })) + +vi.mock('@/executor/handlers/workflow/workflow-handler', () => ({ + WorkflowBlockHandler: class { + execute = mockExecute + }, +})) + +import type { PiiBlockOutputRedaction } from '@/executor/execution/types' +import { runWorkflowTool } from '@/executor/handlers/workflow/workflow-tool-runner' + +const PII_POLICY: PiiBlockOutputRedaction = { + enabled: true, + entityTypes: ['EMAIL_ADDRESS'], + language: 'en', +} + +describe('runWorkflowTool execution context', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecute.mockResolvedValue({ success: true }) + }) + + it("runs the child under the invoking run's environment variables", async () => { + await runWorkflowTool( + { workflowId: 'wf-child', _context: { workspaceId: 'ws-1' } }, + { environmentVariables: { MY_API_KEY: 'secret-value' } } + ) + + const [ctxArg] = mockExecute.mock.calls[0] + expect(ctxArg.environmentVariables).toEqual({ MY_API_KEY: 'secret-value' }) + }) + + it("forwards the invoking run's block-output redaction policy", async () => { + await runWorkflowTool( + { workflowId: 'wf-child', _context: { workspaceId: 'ws-1' } }, + { environmentVariables: {}, piiBlockOutputRedaction: PII_POLICY } + ) + + const [ctxArg] = mockExecute.mock.calls[0] + expect(ctxArg.piiBlockOutputRedaction).toBe(PII_POLICY) + }) + + it('ignores an env map smuggled in through the model-reachable _context bag', async () => { + const modelSuppliedContext = { + workspaceId: 'ws-1', + environmentVariables: { MY_API_KEY: 'model-injected' }, + } + + await runWorkflowTool( + { workflowId: 'wf-child', _context: modelSuppliedContext }, + { environmentVariables: { MY_API_KEY: 'trusted' } } + ) + + const [ctxArg] = mockExecute.mock.calls[0] + expect(ctxArg.environmentVariables).toEqual({ MY_API_KEY: 'trusted' }) + }) +}) diff --git a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts index 5e8a7c90fc9..dee4a388891 100644 --- a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts @@ -4,6 +4,7 @@ import { generateId } from '@sim/utils/id' import { calculateCostSummary } from '@/lib/logs/execution/logging-factory' import type { TraceSpan } from '@/lib/logs/types' import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' +import type { PiiBlockOutputRedaction } from '@/executor/execution/types' import { buildCustomBlockExecutionContext, type CustomBlockExecutorContext, @@ -47,14 +48,21 @@ interface WorkflowToolParams { * On failure the result carries the structured error + the child executionId * in `output` so parent workflows can route on `error.code` and report a * reproducible handle to the workflow's provider. + * + * The child runs under the invoking run's environment variables and block-output + * redaction policy, matching the canvas workflow block — `workflow-handler.ts` + * keeps both from the parent context on the non-custom branch. The handler's + * same-workspace assert bounds that forwarding to a single workspace. */ export async function runWorkflowTool( params: WorkflowToolParams, options: { + environmentVariables: Record abortSignal?: AbortSignal resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry executorDelegationOrigin?: ExecutorDelegationOrigin - } = {} + piiBlockOutputRedaction?: PiiBlockOutputRedaction + } ): Promise { if (!params.workflowId) { return { success: false, output: {}, error: 'Missing workflowId' } diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index fbeda47c273..32d04a9d868 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -1519,6 +1519,56 @@ describe('executeTool Function', () => { expect(global.fetch).not.toHaveBeenCalled() }) + it("hands the in-process runner the invoking run's env and redaction policy", async () => { + mockRunWorkflowTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + const piiBlockOutputRedaction = { + enabled: true, + entityTypes: ['EMAIL_ADDRESS'], + language: 'en', + } + + await executeTool( + 'workflow_executor_child-workflow', + { + workflowId: 'child-workflow', + _context: { environmentVariables: { MY_API_KEY: 'model-injected' } }, + }, + { + executionContext: createToolExecutionContext({ + environmentVariables: { MY_API_KEY: 'parent-secret' }, + piiBlockOutputRedaction, + }), + } + ) + + expect(mockRunWorkflowTool).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + environmentVariables: { MY_API_KEY: 'parent-secret' }, + piiBlockOutputRedaction, + }) + ) + }) + + it('leaves the custom-block runner without the consumer redaction policy', async () => { + mockRunCustomBlockTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + + await executeTool( + 'deployed_block_executor_custom_block_123', + { blockType: 'custom_block_123' }, + { + executionContext: createToolExecutionContext({ + environmentVariables: { MY_API_KEY: 'consumer-secret' }, + piiBlockOutputRedaction: { enabled: true, entityTypes: [], language: 'en' }, + }), + } + ) + + const options = mockRunCustomBlockTool.mock.calls[0]?.[1] as Record + expect(options).not.toHaveProperty('environmentVariables') + expect(options).not.toHaveProperty('piiBlockOutputRedaction') + }) + it('overwrites custom-block tool context with the trusted workflow scope', async () => { const executionContext = createToolExecutionContext({ userId: 'trusted-user', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 04e2b11525c..f033b4e845e 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1789,6 +1789,11 @@ async function executeToolImplementation( abortSignal: effectiveSignal, resolvedSecretTraceRegistry, executorDelegationOrigin: executionContext?.executorDelegationOrigin, + // Trusted `executionContext`, never `_context` — that bag spreads + // model-reachable `contextParams._context` first, so a model could otherwise + // inject its own env map or disable redaction. + environmentVariables: executionContext?.environmentVariables ?? {}, + piiBlockOutputRedaction: executionContext?.piiBlockOutputRedaction, } ) const endTime = new Date()