@@ -17,6 +17,7 @@ import {
1717 extractBranchIndex ,
1818 isBranchNodeId ,
1919} from '@/executor/utils/subflow-utils'
20+ import { CONDITION_READS_ENVIRONMENT_KEY } from '@/executor/variables/resolver'
2021import type { SerializedBlock } from '@/serializer/types'
2122import { executeTool } from '@/tools'
2223import type { ToolResponse } from '@/tools/types'
@@ -29,6 +30,8 @@ interface ConditionEntry {
2930 id : string
3031 title : string
3132 value : string
33+ /** Set by the resolver from the author's pre-resolution expression. */
34+ [ CONDITION_READS_ENVIRONMENT_KEY ] ?: boolean
3235}
3336
3437/** Verdict for a whole condition list evaluated in one function execution. */
@@ -97,32 +100,36 @@ function buildConditionScript(expressions: string[], evalContext: Record<string,
97100 * sandbox the full environment only widens what a future defect in this path could reach —
98101 * the whole map was readable as the `environmentVariables` global.
99102 *
100- * Both scans read the expressions rather than the built script, which also carries the source
101- * block's output as data. Reading that data would let it decide what the sandbox holds: a
102- * payload containing the word `environmentVariables` would restore the whole map, and one
103- * containing `{{SECRET}}` would mount that secret and have the compiler expand it into the
104- * data — a caller choosing which secret materializes next to it. Every legitimate route to a
105- * secret runs through an expression, including a workflow variable holding `{{NAME}}`, because
106- * the resolver inlines that value into the expression before this runs.
103+ * Neither signal is read from the built script, which also carries the source block's output as
104+ * data. Reading that data would let it decide what the sandbox holds — a payload containing
105+ * `{{SECRET}}` would mount that secret and have the compiler expand it beside the payload.
106+ * Placeholders are therefore read from the expressions, which is where every legitimate route
107+ * to a secret passes, including a workflow variable holding `{{NAME}}`: the resolver inlines
108+ * that value into the expression before this runs.
107109 *
108- * Within an expression the bare `environmentVariables` identifier is enough, rather than a
109- * member access. Narrowing the secrets an expression can see when it does reach for the map by
110- * some shape the pattern did not anticipate — `environmentVariables?.FLAG`, or a read through
111- * `Object.keys` — would route the run down a branch the author did not write, silently.
112- * Matching too widely only costs the narrowing itself, and never mounts more than this path
113- * already mounted.
110+ * A direct read of the environment map is not read from the resolved expression either, for the
111+ * same reason one step further in: resolved data is quoted inside it, so a payload containing
112+ * the word would be indistinguishable from the author reaching for the map. The resolver
113+ * records the answer from the author's pre-resolution text instead. When that record is absent
114+ * — a caller that did not resolve through it — the expression is scanned as a fallback, because
115+ * narrowing a read this missed would route the run down a branch the author did not write,
116+ * silently, while matching too widely only costs the narrowing.
114117 */
115- function scopeConditionSecrets ( expressions : string [ ] ) : {
118+ function scopeConditionSecrets ( conditions : ConditionEntry [ ] ) : {
116119 secretScope : 'all' | 'selected'
117120 mountedSecrets : string [ ]
118121} {
119- if ( expressions . some ( ( expression ) => / \b e n v i r o n m e n t V a r i a b l e s \b / . test ( expression ) ) ) {
122+ const readsEnvironment = conditions . some ( ( condition ) => {
123+ const recorded = condition [ CONDITION_READS_ENVIRONMENT_KEY ]
124+ return recorded ?? / \b e n v i r o n m e n t V a r i a b l e s \b / . test ( condition . value )
125+ } )
126+ if ( readsEnvironment ) {
120127 return { secretScope : 'all' , mountedSecrets : [ ] }
121128 }
122129
123130 const named = new Set < string > ( )
124- for ( const expression of expressions ) {
125- for ( const match of expression . matchAll ( createEnvVarPattern ( ) ) ) {
131+ for ( const condition of conditions ) {
132+ for ( const match of String ( condition . value ?? '' ) . matchAll ( createEnvVarPattern ( ) ) ) {
126133 named . add ( String ( match [ 1 ] ) . trim ( ) )
127134 }
128135 }
@@ -141,11 +148,11 @@ function scopeConditionSecrets(expressions: string[]): {
141148async function runConditionCode (
142149 ctx : ExecutionContext ,
143150 code : string ,
144- expressions : string [ ] ,
151+ conditions : ConditionEntry [ ] ,
145152 currentNodeId ?: string
146153) : Promise < ToolResponse > {
147154 const { blockNameMapping, blockOutputSchemas } = collectBlockData ( ctx , currentNodeId )
148- const { secretScope, mountedSecrets } = scopeConditionSecrets ( expressions )
155+ const { secretScope, mountedSecrets } = scopeConditionSecrets ( conditions )
149156
150157 return executeTool (
151158 'function_execute' ,
@@ -188,14 +195,15 @@ function isTimeoutFailure(error: string | undefined): boolean {
188195/** Evaluates the whole condition list in a single function execution. */
189196async function evaluateConditionList (
190197 ctx : ExecutionContext ,
191- expressions : string [ ] ,
198+ conditions : ConditionEntry [ ] ,
192199 evalContext : Record < string , unknown > ,
193200 currentNodeId ?: string
194201) : Promise < ConditionEvaluation > {
202+ const expressions = conditions . map ( ( condition ) => String ( condition . value || '' ) )
195203 const result = await runConditionCode (
196204 ctx ,
197205 buildConditionScript ( expressions , evalContext ) ,
198- expressions ,
206+ conditions ,
199207 currentNodeId
200208 )
201209
@@ -275,12 +283,13 @@ async function evaluateConditionList(
275283 */
276284async function evaluateSingleCondition (
277285 ctx : ExecutionContext ,
278- expression : string ,
286+ condition : ConditionEntry ,
279287 evalContext : Record < string , unknown > ,
280288 currentNodeId ?: string
281289) : Promise < boolean > {
290+ const expression = String ( condition . value || '' )
282291 const code = `const context = ${ JSON . stringify ( evalContext ) } ;\nreturn ${ buildBooleanTest ( expression ) } `
283- const result = await runConditionCode ( ctx , code , [ expression ] , currentNodeId )
292+ const result = await runConditionCode ( ctx , code , [ condition ] , currentNodeId )
284293
285294 if ( ! result . success ) {
286295 if ( result . retryable === false ) {
@@ -464,11 +473,9 @@ export class ConditionBlockHandler implements BlockHandler {
464473 ) : Promise < ConditionEntry | null > {
465474 if ( conditions . length === 0 ) return null
466475
467- const expressions = conditions . map ( ( condition ) => String ( condition . value || '' ) )
468-
469476 let evaluation : ConditionEvaluation
470477 try {
471- evaluation = await evaluateConditionList ( ctx , expressions , evalContext , currentNodeId )
478+ evaluation = await evaluateConditionList ( ctx , conditions , evalContext , currentNodeId )
472479 } catch ( error ) {
473480 if ( isNonRetryableExecutionError ( error ) ) throw error
474481 evaluation = {
@@ -520,7 +527,7 @@ export class ConditionBlockHandler implements BlockHandler {
520527 try {
521528 const conditionMet = await evaluateSingleCondition (
522529 ctx ,
523- String ( condition . value || '' ) ,
530+ condition ,
524531 evalContext ,
525532 currentNodeId
526533 )
0 commit comments