Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions apps/sim/executor/handlers/condition/condition-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,107 @@ describe('ConditionBlockHandler', () => {
)
})

it('mounts only the secrets the condition names', async () => {
mockExecuteTool.mockResolvedValueOnce(matchedAt(0))

const conditions = [
{ id: 'cond1', title: 'if', value: '"{{ROUTE_KEY}}" === "beta"' },
{ id: 'else1', title: 'else', value: '' },
]

await handler.execute(mockContext, mockBlock, { conditions: JSON.stringify(conditions) })

const [, toolParams] = mockExecuteTool.mock.calls[0]
expect(toolParams.secretScope).toBe('selected')
expect(toolParams.mountedSecrets).toEqual(['ROUTE_KEY'])
})

it('denies every secret to a condition that names none', async () => {
mockExecuteTool.mockResolvedValueOnce(matchedAt(0))

const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]

await handler.execute(mockContext, mockBlock, { conditions: JSON.stringify(conditions) })

const [, toolParams] = mockExecuteTool.mock.calls[0]
expect(toolParams.secretScope).toBe('selected')
expect(toolParams.mountedSecrets).toEqual([])
})

it('does not let resolved data decide which secrets the sandbox holds', async () => {
// The script carries the source block's output as data. Reading that data for either
// signal would let a caller pick what materializes beside it — the whole map by naming
// the global, or one secret by naming its placeholder.
mockExecuteTool.mockResolvedValueOnce(matchedAt(0))
mockContext.blockStates.set('source-block-1', {
output: { text: 'environmentVariables.OPENAI_API_KEY {{OPENAI_API_KEY}}' },
executed: true,
executionTime: 0,
} as BlockState)

const conditions = [
{ id: 'cond1', title: 'if', value: `context.text === 'x'` },
{ id: 'else1', title: 'else', value: '' },
]

await handler.execute(mockContext, mockBlock, { conditions: JSON.stringify(conditions) })

const [, toolParams] = mockExecuteTool.mock.calls[0]
expect(toolParams.code).toContain('{{OPENAI_API_KEY}}')
expect(toolParams.secretScope).toBe('selected')
expect(toolParams.mountedSecrets).toEqual([])
})

it('trusts the resolver record over the word appearing in a resolved expression', async () => {
// The resolver saw the author's text before any value was inlined; the expression by now
// carries trigger data, where the same word means nothing.
mockExecuteTool.mockResolvedValueOnce(matchedAt(0))

const conditions = [
{
id: 'cond1',
title: 'if',
value: `'environmentVariables.OPENAI_API_KEY' === 'x'`,
_readsEnvironmentVariables: false,
},
{ id: 'else1', title: 'else', value: '' },
]

await handler.execute(mockContext, mockBlock, { conditions: JSON.stringify(conditions) })

const [, toolParams] = mockExecuteTool.mock.calls[0]
expect(toolParams.secretScope).toBe('selected')
expect(toolParams.mountedSecrets).toEqual([])
})

it('keeps the whole environment for a condition that reads the environment directly', async () => {
// Every shape an expression can reach the map through, including the ones a member-access
// pattern would miss — narrowing one of those would route the run silently.
const reads = [
'environmentVariables.ROUTE_KEY === "beta"',
'environmentVariables["ROUTE_KEY"] === "beta"',
'environmentVariables?.ROUTE_KEY === "beta"',
'Object.keys(environmentVariables).length > 0',
]

for (const value of reads) {
mockExecuteTool.mockReset()
mockExecuteTool.mockResolvedValueOnce(matchedAt(0))
const conditions = [
{ id: 'cond1', title: 'if', value },
{ id: 'else1', title: 'else', value: '' },
]

await handler.execute(mockContext, mockBlock, { conditions: JSON.stringify(conditions) })

const [, toolParams] = mockExecuteTool.mock.calls[0]
expect(toolParams.secretScope, `condition ${value}`).toBe('all')
}
})

it('should never forward collected block outputs in the request body', async () => {
mockCollectBlockData.mockReturnValueOnce({
blockData: { 'huge-block': { payload: 'x'.repeat(1024) } },
Expand Down
67 changes: 60 additions & 7 deletions apps/sim/executor/handlers/condition/condition-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import type { BlockOutput } from '@/blocks/types'
import { BlockType, DEFAULTS, EDGE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
import {
buildBranchNodeId,
extractBaseBlockId,
extractBranchIndex,
isBranchNodeId,
} from '@/executor/utils/subflow-utils'
import { CONDITION_READS_ENVIRONMENT_KEY } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import type { ToolResponse } from '@/tools/types'
Expand All @@ -28,6 +30,8 @@ interface ConditionEntry {
id: string
title: string
value: string
/** Set by the resolver from the author's pre-resolution expression. */
[CONDITION_READS_ENVIRONMENT_KEY]?: boolean
}

/** Verdict for a whole condition list evaluated in one function execution. */
Expand Down Expand Up @@ -88,6 +92,50 @@ function buildConditionScript(expressions: string[], evalContext: Record<string,
].join('\n')
}

/**
* Narrows the secrets a condition evaluation can read to the ones its script names.
*
* A condition reaches a secret by writing `{{NAME}}`, which the execution-boundary compiler
* binds. Nothing else in the script needs the workspace's other secrets, so handing the
* sandbox the full environment only widens what a future defect in this path could reach —
* the whole map was readable as the `environmentVariables` global.
*
* Neither signal is read from the built script, which also carries the source block's output as
* data. Reading that data would let it decide what the sandbox holds — a payload containing
* `{{SECRET}}` would mount that secret and have the compiler expand it beside the payload.
* Placeholders are therefore read from the expressions, which is where every legitimate route
* to a secret passes, including a workflow variable holding `{{NAME}}`: the resolver inlines
* that value into the expression before this runs.
*
* A direct read of the environment map is not read from the resolved expression either, for the
* same reason one step further in: resolved data is quoted inside it, so a payload containing
* the word would be indistinguishable from the author reaching for the map. The resolver
* records the answer from the author's pre-resolution text instead. When that record is absent
* — a caller that did not resolve through it — the expression is scanned as a fallback, because
* narrowing a read this missed would route the run down a branch the author did not write,
* silently, while matching too widely only costs the narrowing.
*/
function scopeConditionSecrets(conditions: ConditionEntry[]): {
secretScope: 'all' | 'selected'
mountedSecrets: string[]
} {
const readsEnvironment = conditions.some((condition) => {
const recorded = condition[CONDITION_READS_ENVIRONMENT_KEY]
return recorded ?? /\benvironmentVariables\b/.test(condition.value)
})
if (readsEnvironment) {
return { secretScope: 'all', mountedSecrets: [] }
}

const named = new Set<string>()
for (const condition of conditions) {
for (const match of String(condition.value ?? '').matchAll(createEnvVarPattern())) {
named.add(String(match[1]).trim())
}
}
return { secretScope: 'selected', mountedSecrets: [...named] }
}

/**
* Runs condition code through the shared function execution boundary.
*
Expand All @@ -100,15 +148,19 @@ function buildConditionScript(expressions: string[], evalContext: Record<string,
async function runConditionCode(
ctx: ExecutionContext,
code: string,
conditions: ConditionEntry[],
currentNodeId?: string
): Promise<ToolResponse> {
const { blockNameMapping, blockOutputSchemas } = collectBlockData(ctx, currentNodeId)
const { secretScope, mountedSecrets } = scopeConditionSecrets(conditions)

return executeTool(
'function_execute',
{
code,
timeout: CONDITION_TIMEOUT_MS,
secretScope,
mountedSecrets,
envVars: normalizeStringRecord(ctx.environmentVariables),
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData: {},
Expand Down Expand Up @@ -143,13 +195,15 @@ function isTimeoutFailure(error: string | undefined): boolean {
/** Evaluates the whole condition list in a single function execution. */
async function evaluateConditionList(
ctx: ExecutionContext,
expressions: string[],
conditions: ConditionEntry[],
evalContext: Record<string, unknown>,
currentNodeId?: string
): Promise<ConditionEvaluation> {
const expressions = conditions.map((condition) => String(condition.value || ''))
const result = await runConditionCode(
ctx,
buildConditionScript(expressions, evalContext),
conditions,
currentNodeId
)

Expand Down Expand Up @@ -229,12 +283,13 @@ async function evaluateConditionList(
*/
async function evaluateSingleCondition(
ctx: ExecutionContext,
expression: string,
condition: ConditionEntry,
evalContext: Record<string, unknown>,
currentNodeId?: string
): Promise<boolean> {
const expression = String(condition.value || '')
const code = `const context = ${JSON.stringify(evalContext)};\nreturn ${buildBooleanTest(expression)}`
const result = await runConditionCode(ctx, code, currentNodeId)
const result = await runConditionCode(ctx, code, [condition], currentNodeId)

if (!result.success) {
if (result.retryable === false) {
Expand Down Expand Up @@ -418,11 +473,9 @@ export class ConditionBlockHandler implements BlockHandler {
): Promise<ConditionEntry | null> {
if (conditions.length === 0) return null

const expressions = conditions.map((condition) => String(condition.value || ''))

let evaluation: ConditionEvaluation
try {
evaluation = await evaluateConditionList(ctx, expressions, evalContext, currentNodeId)
evaluation = await evaluateConditionList(ctx, conditions, evalContext, currentNodeId)
} catch (error) {
if (isNonRetryableExecutionError(error)) throw error
evaluation = {
Expand Down Expand Up @@ -474,7 +527,7 @@ export class ConditionBlockHandler implements BlockHandler {
try {
const conditionMet = await evaluateSingleCondition(
ctx,
String(condition.value || ''),
condition,
evalContext,
currentNodeId
)
Expand Down
33 changes: 33 additions & 0 deletions apps/sim/executor/utils/code-formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,36 @@ export function formatLiteralForCode(value: unknown, language: 'javascript' | 'p
}
return JSON.stringify(value)
}

/**
* Escapes text so it cannot terminate whatever JavaScript literal it is spliced into.
*
* Condition expressions are user-authored JavaScript into which resolved references are
* inlined as source, so the author's quoting — not this module's — decides which string
* context the value lands in. Escaping only the quote the emitted literal opens leaves
* `"`, a backtick, `${`, and `/` live, and `"<start.input>".includes('urgent')` then lets
* trigger data close the author's string and run as code in the condition sandbox.
*
* So every terminator of every JavaScript string context is escaped, not just the one the
* emitted literal opens. `\"`, `` \` ``, `\$`, and `\/` are identity escapes in JavaScript,
* so the value a condition compares is byte-identical to what it was before — this is a
* syntax guard, not a value transform.
*
* JavaScript only: `\$`, `` \` `` and `\/` are not identity escapes in Python, where they
* would change the value. Code blocks bind their values as runtime context variables
* instead of splicing them, which is why they need no escaping in any language.
*/
export function escapeInertStringContent(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/['"`$/]/g, '\\$&')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
}

/** Wraps {@link escapeInertStringContent} output as a complete JavaScript string literal. */
export function formatInertStringLiteral(value: string, quote: '"' | "'" = "'"): string {
return `${quote}${escapeInertStringContent(value)}${quote}`
}
Loading
Loading