Skip to content

Commit aa5c8f0

Browse files
icecrasher321claude
andcommitted
fix(executor): teach the code scanner about regex literals
A regex body is the one place a lone quote is not a string delimiter, and the scanner did not track regex literals at all: `/['"]/` left it believing everything after it sat inside a string. Every later reference was then formatted for a context it was not in — a quoted object reference stayed raw source, and after the previous commit a bare one was emitted as escaped JSON, which cannot parse, so a valid condition threw instead of routing. The scan now enters regex mode where a `/` can only be a regex — division always follows a value, so the preceding token decides — and tracks escapes and character classes until the closing delimiter. A reference inside a regex reports its own context, so its JSON is escaped as pattern text rather than spliced with delimiters the data could forge. The same scanner decides how function-block references are spliced, so this also repairs quoting for code that matches on a quote-bearing pattern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 49f568b commit aa5c8f0

2 files changed

Lines changed: 159 additions & 1 deletion

File tree

apps/sim/executor/variables/resolver.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,46 @@ describe('VariableResolver function block inputs', () => {
363363
expect(runResolvedCondition(expression).matched).toBe(true)
364364
})
365365

366+
it('evaluates references that follow a regex literal, quote-bearing or not', async () => {
367+
// A regex body is the one place a lone quote is not a string delimiter. Reading it as one
368+
// left every later reference formatted for a context it was not in — a quoted object
369+
// reference stayed raw source, and a bare one was emitted as escaped JSON that cannot parse.
370+
const cases: Array<{ value: string; result: unknown; expected: boolean }> = [
371+
// Every case reaches its reference — a short-circuit would pass on a formatter that
372+
// emits source the sandbox cannot parse, which is the failure being pinned here.
373+
{
374+
value: `/['"a]/.test('a') && <producer.result>.count === 2`,
375+
result: { count: 2 },
376+
expected: true,
377+
},
378+
{ value: `/['"]/.test('a') || <producer.result> === 'x'`, result: 'x', expected: true },
379+
{
380+
value: `/it's/.test('a') || "<producer.result>".includes('b')`,
381+
result: 'abc',
382+
expected: true,
383+
},
384+
{
385+
value: `/[a-z]/.test('a') && <producer.result>.count === 2`,
386+
result: { count: 2 },
387+
expected: true,
388+
},
389+
// Division, not a regex: the scan must not swallow the rest of the expression.
390+
{ value: `<producer.result>.total / 2 === 5`, result: { total: 10 }, expected: true },
391+
{
392+
value: `(<producer.result>.total / 2) === 5 && '<producer.result>'.length > 0`,
393+
result: { total: 10 },
394+
expected: true,
395+
},
396+
]
397+
398+
for (const { value, result, expected } of cases) {
399+
const expression = await resolveConditionWithBlockOutput(value, result)
400+
const verdict = runResolvedCondition(expression)
401+
expect(verdict.injected, `condition ${value} executed data: ${expression}`).toBe(false)
402+
expect(verdict.matched, `condition ${value} resolved to: ${expression}`).toBe(expected)
403+
}
404+
})
405+
366406
it('keeps quoted and bare condition references comparing what they compared before', async () => {
367407
const cases: Array<{ value: string; result: unknown; expected: boolean }> = [
368408
{ value: `<producer.result> === 'urgent'`, result: 'urgent', expected: true },

apps/sim/executor/variables/resolver.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,12 @@ function isStructurallyInertConditionLiteral(value: string): boolean {
146146
}
147147

148148
type ShellQuoteContext = 'single' | 'double' | null
149-
type CodeStringQuoteContext = ShellQuoteContext | 'triple-single' | 'triple-double' | 'template'
149+
type CodeStringQuoteContext =
150+
| ShellQuoteContext
151+
| 'triple-single'
152+
| 'triple-double'
153+
| 'template'
154+
| 'regex'
150155
type CodeScanMode =
151156
| { type: 'normal' }
152157
| { type: 'single' }
@@ -157,6 +162,37 @@ type CodeScanMode =
157162
| { type: 'template-expression'; depth: number }
158163
| { type: 'line-comment' }
159164
| { type: 'block-comment' }
165+
| { type: 'regex'; inCharacterClass: boolean }
166+
167+
/**
168+
* Characters after which a `/` opens a regular expression rather than dividing.
169+
*
170+
* The scanner has to tell the two apart, because a regex body is the one place a lone quote
171+
* is not a string delimiter: `/['"]/` left the scan believing everything after it sat inside
172+
* a string, and every reference past that point was then formatted for the wrong context.
173+
* Division always follows a value — an identifier, literal, `)`, or `]` — so anything else
174+
* ending the preceding token means a regex may start.
175+
*/
176+
const JAVASCRIPT_REGEX_ALLOWED_AFTER = new Set('(,=:[!&|?{};+-*%^~<>/'.split(''))
177+
178+
const WHITESPACE_CHAR = /\s/
179+
180+
/** Keywords a regex may directly follow, where the preceding token is a word rather than punctuation. */
181+
const JAVASCRIPT_REGEX_ALLOWED_AFTER_KEYWORDS = new Set([
182+
'return',
183+
'typeof',
184+
'instanceof',
185+
'in',
186+
'of',
187+
'new',
188+
'delete',
189+
'void',
190+
'case',
191+
'do',
192+
'else',
193+
'yield',
194+
'await',
195+
])
160196

161197
export class VariableResolver {
162198
private resolvers: Resolver[]
@@ -1073,6 +1109,35 @@ export class VariableResolver {
10731109
)
10741110
}
10751111

1112+
/**
1113+
* Whether a `/` at this point opens a regular expression rather than dividing.
1114+
*
1115+
* Division always follows a value, so the preceding token decides: an identifier that is not
1116+
* one of the keywords a regex may follow, a number, a `)`, or a `]` means division, and
1117+
* anything else means a regex may start. Guessing wrong is not silent — the scan would swallow
1118+
* text up to the next `/` — so the check reads the actual preceding token rather than assuming.
1119+
*/
1120+
private canStartJavaScriptRegex(template: string, previousSignificantIndex: number): boolean {
1121+
if (previousSignificantIndex < 0) {
1122+
return true
1123+
}
1124+
const previous = template[previousSignificantIndex]
1125+
if (JAVASCRIPT_REGEX_ALLOWED_AFTER.has(previous)) {
1126+
return true
1127+
}
1128+
if (!this.isJavaScriptIdentifierChar(previous)) {
1129+
return false
1130+
}
1131+
1132+
let start = previousSignificantIndex
1133+
while (start > 0 && this.isJavaScriptIdentifierChar(template[start - 1])) {
1134+
start--
1135+
}
1136+
return JAVASCRIPT_REGEX_ALLOWED_AFTER_KEYWORDS.has(
1137+
template.slice(start, previousSignificantIndex + 1)
1138+
)
1139+
}
1140+
10761141
private matchesKeywordAt(template: string, index: number, keyword: string): boolean {
10771142
if (!template.startsWith(keyword, index)) {
10781143
return false
@@ -1200,6 +1265,7 @@ export class VariableResolver {
12001265
): CodeStringQuoteContext {
12011266
const isPython = language === 'python'
12021267
const modes: CodeScanMode[] = [{ type: 'normal' }]
1268+
let lastSignificantIndex = -1
12031269

12041270
for (let i = 0; i < index; i++) {
12051271
const char = template[i]
@@ -1221,6 +1287,31 @@ export class VariableResolver {
12211287
continue
12221288
}
12231289

1290+
if (mode.type === 'regex') {
1291+
if (char === '\\') {
1292+
i++
1293+
continue
1294+
}
1295+
// A regex literal cannot span a line, so an unterminated one means the `/` was
1296+
// division after all; dropping the mode keeps the rest of the scan honest.
1297+
if (char === '\n') {
1298+
modes.pop()
1299+
continue
1300+
}
1301+
if (char === '[') {
1302+
mode.inCharacterClass = true
1303+
continue
1304+
}
1305+
if (char === ']') {
1306+
mode.inCharacterClass = false
1307+
continue
1308+
}
1309+
if (char === '/' && !mode.inCharacterClass) {
1310+
modes.pop()
1311+
}
1312+
continue
1313+
}
1314+
12241315
if (mode.type === 'single' || mode.type === 'double') {
12251316
const quote = mode.type === 'single' ? "'" : '"'
12261317
if (char === '\\') {
@@ -1273,6 +1364,18 @@ export class VariableResolver {
12731364
i++
12741365
continue
12751366
}
1367+
const previousSignificantIndex = lastSignificantIndex
1368+
if (!WHITESPACE_CHAR.test(char)) {
1369+
lastSignificantIndex = i
1370+
}
1371+
if (
1372+
!isPython &&
1373+
char === '/' &&
1374+
this.canStartJavaScriptRegex(template, previousSignificantIndex)
1375+
) {
1376+
modes.push({ type: 'regex', inCharacterClass: false })
1377+
continue
1378+
}
12761379
if (isPython && char === "'" && next === "'" && template[i + 2] === "'") {
12771380
modes.push({ type: 'triple-single' })
12781381
i += 2
@@ -1322,6 +1425,18 @@ export class VariableResolver {
13221425
i++
13231426
continue
13241427
}
1428+
const previousSignificantIndex = lastSignificantIndex
1429+
if (!WHITESPACE_CHAR.test(char)) {
1430+
lastSignificantIndex = i
1431+
}
1432+
if (
1433+
!isPython &&
1434+
char === '/' &&
1435+
this.canStartJavaScriptRegex(template, previousSignificantIndex)
1436+
) {
1437+
modes.push({ type: 'regex', inCharacterClass: false })
1438+
continue
1439+
}
13251440
if (isPython && char === "'" && next === "'" && template[i + 2] === "'") {
13261441
modes.push({ type: 'triple-single' })
13271442
i += 2
@@ -1338,6 +1453,9 @@ export class VariableResolver {
13381453
}
13391454

13401455
const mode = modes[modes.length - 1]
1456+
if (mode.type === 'regex') {
1457+
return 'regex'
1458+
}
13411459
if (
13421460
mode.type === 'single' ||
13431461
mode.type === 'double' ||

0 commit comments

Comments
 (0)