fix(executor): give the workflow agent tool the caller's env and PII policy (#6611)

A workflow attached as an Agent (or Pi) tool ran its entire child execution
with an empty environment-variable map and no block-output redaction policy.

Mechanism. `tools/index.ts` short-circuits `workflow_executor` into
`runWorkflowTool`, which builds its synthetic parent `ExecutionContext` with
`buildCustomBlockExecutionContext`. That builder was written for the
custom-block (deploy-as-block) path and hardcoded `environmentVariables: {}` —
safe there only because `WorkflowBlockHandler.executeCore` re-derives the
publisher's env inside `if (isCustomBlock)`. The workflow-tool path's synthetic
block carries `metadata.id: 'workflow_input'`, so `isCustomBlock` is false, the
re-derivation is skipped, and `{}` flows through `childEnvVarValues` into the
sub-Executor. `DAGExecutor` has no fallback and `EnvResolver` returns the raw
reference on a miss, so a child block field of `Bearer {{MY_API_KEY}}` was
transmitted to the third party verbatim and 401'd — silently, with the variable
name disclosed. The same builder never set `piiBlockOutputRedaction`, so
`block-executor`'s in-flight masking was disabled for every child block of orgs
that had explicitly enabled that stage. Both landed as unnoticed side effects of
#5273, whose stated goals were admission slots, log rows, cost roll-up and
structured errors; #6539 later patched a third dropped field on the same context
without noticing these two.

Fix. Thread both values through the runner `options` bag — never `params._context`,
which spreads model-reachable `contextParams._context` first and would let a model
inject its own env map or disable redaction. `executeTool` reads them off the
trusted `executionContext`, which also covers the Pi block, whose tool loop calls
`executeTool` with `executionContext: ctx` on the identical path.

`environmentVariables` is required rather than optional-with-a-default. Silent
omission is precisely the failure mode here and in #6539; making it required turns
the next caller's omission into a compile error. `runCustomBlockTool` now passes
`{}` explicitly, so that path is unchanged at runtime. `piiBlockOutputRedaction`
stays optional deliberately: `undefined` is its correct value for the many tenants
with no policy, whereas `{}` for env is a wrong identity rather than a default.
The builder's TSDoc states both halves of that asymmetry.

Identity semantics — this restores function but does not restore main's identity.
On main this tool was an HTTP hop into execution-core, which derived the env from
the CHILD workflow's owner, so the child got the child owner's personal env plus
the child workspace's env. Forwarding the caller's map gives the child the PARENT
CALLER's personal env: a different identity, not a subset. That is the deliberate
choice, because it is byte-identical to the long-standing canvas workflow block,
it is bounded to one workspace by `assertChildWorkflowInWorkspace` on this branch,
and it is the only variant consistent with the parent `resolvedSecretTraceRegistry`
this path already forwards. The narrow case that worked on main and still will not:
a same-workspace child owned by another member that relied on THAT member's
personal environment variable.

The `deployed_block_executor` call site deliberately gets neither value: custom
blocks skip the same-workspace assert and run cross-workspace under the
publisher's identity, so the consumer's env and redaction rules are the wrong
tenant's. A test pins that so a later refactor cannot unify the branches silently.

Tests. Three suites pin the fix itself (runner, builder, `executeTool` dispatch)
and go red without it. A fourth case in `workflow-handler.test.ts` pins the last
hop — `ctx.environmentVariables` -> `childEnvVarValues` -> the sub-Executor's
`envVarValues`, plus `piiBlockOutputRedaction` — on the NON-custom branch. That
hop is untouched staging code, so that case passes either way by construction; it
exists so a future change to the branch that distinguishes the two paths cannot
silently undo this fix downstream of the builder.

Out of scope, deliberately: `enforceCredentialAccess` is dropped by the same
synthetic context, but on main this path ran under an internal JWT with
`useAuthenticatedUserAsActor === false`, so forwarding the parent's value would
TIGHTEN behavior versus main and could break currently-working child runs
mid-release. It needs its own deliberate change — and it now compounds with this
one, since the child runs with the parent's decrypted env while credential-access
enforcement stays off. The `input` redaction stage (masking the LLM-authored
inputMapping) is also not restored — `ExecutionContext` has no field for it and
the canvas workflow block never had it either.

Re-enabling masking inside child runs is a live behavior change for affected
tenants: `redactObjectStrings` runs with `onFailure: 'throw'`, so a child agent
tool call that currently succeeds unmasked can now fail closed, which is main's
semantic restored. This belongs in the release note.
This commit is contained in:
Waleed
2026-08-12 01:43:22 -07:00
committed by GitHub
parent 8f85ded35f
commit b33f8e82fe
7 changed files with 254 additions and 29 deletions
@@ -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)
@@ -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<string, string>
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,
})
@@ -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' }
@@ -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' })
})
})
@@ -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<string, string>
abortSignal?: AbortSignal
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
executorDelegationOrigin?: ExecutorDelegationOrigin
} = {}
piiBlockOutputRedaction?: PiiBlockOutputRedaction
}
): Promise<ToolResponse> {
if (!params.workflowId) {
return { success: false, output: {}, error: 'Missing workflowId' }
+50
View File
@@ -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<string, unknown>
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',
+5
View File
@@ -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()