Skip to content

Commit 082deb2

Browse files
authored
fix(executor): copy the env map at the workflow-tool boundary (#6618)
Follow-up hardening to #6611, which began forwarding the invoking run's environment variables into a workflow run as an agent tool. A runtime audit of that change confirmed nothing today writes through `ctx.environmentVariables`, so this is not a live defect. But `tools/index.ts` was the only consumer handing the map across an execution boundary by reference, and it hands it to the longest-lived consumer there is: the child holds it for its entire run. `agent-handler`, `function-handler`, `condition-handler` and `providers/utils` all copy via `normalizeStringRecord` before handing the map anywhere. A future write through the child's reference would corrupt the parent's env and every later sibling tool call in the same agent turn — a cross-run bug with no local symptom. A shallow spread is exact here: the value is typed `Record<string, string>`, and the sub-Executor already re-copies it through `normalizeStringRecord` (`executor.ts:73`), so the child receives a byte-identical map either way. The spread also subsumes the previous `?? {}`, since spreading `undefined` yields `{}`. The test mutates the forwarded map and asserts the parent context is unchanged; it fails without the spread.
1 parent 2054947 commit 082deb2

2 files changed

Lines changed: 28 additions & 1 deletion

File tree

apps/sim/tools/index.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,6 +1550,29 @@ describe('executeTool Function', () => {
15501550
)
15511551
})
15521552

1553+
it('copies the env map so a child run cannot corrupt the parent context', async () => {
1554+
mockRunWorkflowTool.mockResolvedValueOnce({ success: true, output: { ok: true } })
1555+
const executionContext = createToolExecutionContext({
1556+
environmentVariables: { MY_API_KEY: 'parent-secret' },
1557+
})
1558+
1559+
await executeTool(
1560+
'workflow_executor_child-workflow',
1561+
{ workflowId: 'child-workflow' },
1562+
{ executionContext }
1563+
)
1564+
1565+
const forwarded = (mockRunWorkflowTool.mock.calls[0]?.[1] as Record<string, unknown>)
1566+
.environmentVariables as Record<string, string>
1567+
expect(forwarded).toEqual({ MY_API_KEY: 'parent-secret' })
1568+
expect(forwarded).not.toBe(executionContext.environmentVariables)
1569+
1570+
forwarded.MY_API_KEY = 'mutated-by-child'
1571+
forwarded.INJECTED = 'added-by-child'
1572+
1573+
expect(executionContext.environmentVariables).toEqual({ MY_API_KEY: 'parent-secret' })
1574+
})
1575+
15531576
it('leaves the custom-block runner without the consumer redaction policy', async () => {
15541577
mockRunCustomBlockTool.mockResolvedValueOnce({ success: true, output: { ok: true } })
15551578

apps/sim/tools/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1792,7 +1792,11 @@ async function executeToolImplementation(
17921792
// Trusted `executionContext`, never `_context` — that bag spreads
17931793
// model-reachable `contextParams._context` first, so a model could otherwise
17941794
// inject its own env map or disable redaction.
1795-
environmentVariables: executionContext?.environmentVariables ?? {},
1795+
// Copied, not aliased: the child holds this map for its whole run, and a
1796+
// write through it would corrupt the parent's env and every later sibling
1797+
// tool call. Every other consumer of `ctx.environmentVariables` already
1798+
// copies (`normalizeStringRecord`); this boundary is the longest-lived one.
1799+
environmentVariables: { ...executionContext?.environmentVariables },
17961800
piiBlockOutputRedaction: executionContext?.piiBlockOutputRedaction,
17971801
}
17981802
)

0 commit comments

Comments
 (0)