From b7c2635d8fa2b017e0abbae3d28cb641c6d65a99 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 14:27:28 -0700 Subject: [PATCH 1/7] fix(shell): preserve heredoc contexts and reject arithmetic placeholders --- .../code-placeholders/compiler.test.ts | 118 ++++++++++ .../lib/execution/code-placeholders/shell.ts | 216 ++++++++++-------- 2 files changed, 239 insertions(+), 95 deletions(-) diff --git a/apps/sim/lib/execution/code-placeholders/compiler.test.ts b/apps/sim/lib/execution/code-placeholders/compiler.test.ts index 40c0afde1c0..fa3658ba32b 100644 --- a/apps/sim/lib/execution/code-placeholders/compiler.test.ts +++ b/apps/sim/lib/execution/code-placeholders/compiler.test.ts @@ -1014,6 +1014,72 @@ describe('code placeholder compiler', () => { } }) + it.each([ + 'total=$(( {{KEY}} * 2 ))', + 'echo "$(( {{KEY}} * 2 ))"', + 'total=$[ {{KEY}} * 2 ]', + 'echo "$[ {{KEY}} * 2 ]"', + '(( total = {{KEY}} * 2 ))', + 'for (( i = 0; i < {{KEY}}; i++ )); do :; done', + 'echo "$(( 1 + ({{KEY}} * 2) ))"', + 'echo "$(( $(printf %s "{{KEY}}") * 2 ))"', + 'echo "$[ values[0] + {{KEY}} ]"', + 'cat < { + await expect( + compileCodePlaceholders({ + code, + language: CodeLanguage.Shell, + environmentVariables: { KEY: 'x[$(printf unsafe >&2)]' }, + }) + ).rejects.toThrow('is not supported in a shell arithmetic expression') + }) + + it('keeps arithmetic discovery separate from runtime placeholder rejection', async () => { + await expect( + analyzeCodePlaceholders('echo "$(( {{KEY}} * 2 ))"', CodeLanguage.Shell) + ).resolves.toEqual(['KEY']) + const missing = await compileCodePlaceholders({ + code: 'echo "$(( {{MISSING}} + 1 ))"', + language: CodeLanguage.Shell, + }) + expect(missing.code).toContain('{{MISSING}}') + }) + + it('restores shell quote contexts after arithmetic and leaves literal arithmetic text alone', async () => { + const compiled = await compileCodePlaceholders({ + code: [ + 'printf "%s\\n" "$(( (2 + 1) << 1 )):$[ 2 + 1 ]:{{KEY}}"', + "printf '%s\\n' '$(( {{KEY}} ))'", + 'cat </dev/null\nToday's report\nEOF"], + ['quoted', "cat <<'EOF' >/dev/null\nToday's report\nEOF"], + ['double quote in prose', 'cat </dev/null\nSay "hello\nEOF'], + ['literal backtick', "cat <<'EOF' >/dev/null\nAn unmatched `\nEOF"], + ['multiple bodies', "cat </dev/null\nToday's report\nONE\nSay \"hello\nTWO"], + ])('preserves quoted shell values after a %s heredoc', async (_name, prefix) => { + const value = 'one two $(printf unsafe)' + const compiled = await compileCodePlaceholders({ + code: `${prefix}\nprintf "<%s>\\n" "{{KEY}}"`, + language: CodeLanguage.Shell, + environmentVariables: { KEY: value }, + }) + expect(executeShell(compiled.code, compiled.bindings)).toBe(`<${value}>\n`) + }) + it('renders quoted shell heredocs nested in double-quoted command substitutions', async () => { const compiled = await compileCodePlaceholders({ code: [ @@ -1261,6 +1327,58 @@ describe('direct environment reads in shell', () => { expect(unquoted.resolvedSecretNames).toEqual(['API_KEY']) }) + it.each(['EOF', "'EOF'"])( + 'records direct shell reads after a %s heredoc without lexing its prose', + async (delimiter) => { + const compiled = await compileCodePlaceholders({ + code: `cat <<${delimiter} >/dev/null\nToday's report\nEOF\necho "$API_KEY"`, + language: CodeLanguage.Shell, + environmentVariables: { API_KEY: 'synthetic-value' }, + }) + expect(executeShell(compiled.code, [{ name: 'API_KEY', value: 'synthetic-value' }])).toBe( + 'synthetic-value\n' + ) + expect(compiled.resolvedSecretNames).toEqual(['API_KEY']) + } + ) + + it.each(["Today's $API_KEY", '# $API_KEY'])( + 'records direct shell reads in expanding heredoc prose: %s', + async (body) => { + const compiled = await compileCodePlaceholders({ + code: `cat < { + const compiled = await compileCodePlaceholders({ + code: [ + 'cat <<$DELIMITER; echo "$HEADER"', + 'body', + '$DELIMITER', + "cat <<'EOF'", + "Today's $LITERAL", + 'EOF', + 'echo "$AFTER"', + ].join('\n'), + language: CodeLanguage.Shell, + environmentVariables: { + DELIMITER: 'unused-delimiter', + HEADER: 'header-value', + LITERAL: 'unused-literal', + AFTER: 'after-value', + }, + }) + expect(compiled.resolvedSecretNames).toEqual(['HEADER', 'AFTER']) + }) + it('ignores a shell variable that is not a configured secret', async () => { const compiled = await compileCodePlaceholders({ code: 'echo "$PATH $HOME"', diff --git a/apps/sim/lib/execution/code-placeholders/shell.ts b/apps/sim/lib/execution/code-placeholders/shell.ts index 602c44215a8..479fe400b8c 100644 --- a/apps/sim/lib/execution/code-placeholders/shell.ts +++ b/apps/sim/lib/execution/code-placeholders/shell.ts @@ -29,11 +29,14 @@ interface ShellScanFrame { kind: 'root' | 'command' | 'arithmetic' | 'backtick' quote: ShellQuote parenthesisDepth: number + bracketDepth?: number literalRoot: boolean } interface ShellOccurrenceContext { quote: ShellQuote + /** Enclosing arithmetic also re-evaluates the output of a nested command substitution. */ + arithmetic?: boolean unsupported?: 'escaped sequence' } @@ -70,6 +73,31 @@ function shellArithmeticCommandStarts(code: string, index: number): boolean { return previous === undefined || /\s|[;&|()<>]/.test(previous) } +function readShellExpansionStart( + code: string, + index: number, + allowArithmeticCommand: boolean +): { frame: ShellScanFrame; length: number } | undefined { + const dollar = code[index] === '$' + const bracket = dollar && code[index + 1] === '[' + const arithmetic = + bracket || + (dollar && code[index + 1] === '(' && code[index + 2] === '(') || + (allowArithmeticCommand && shellArithmeticCommandStarts(code, index)) + if (!arithmetic && !(dollar && code[index + 1] === '(')) return undefined + + return { + frame: { + kind: arithmetic ? 'arithmetic' : 'command', + quote: 'none', + parenthesisDepth: bracket ? 0 : arithmetic ? 2 : 1, + ...(bracket ? { bracketDepth: 1 } : {}), + literalRoot: false, + }, + length: dollar && arithmetic && !bracket ? 3 : 2, + } +} + function decodeAnsiCCharacter(code: string, index: number): { value: string; end: number } { const character = code[index] const simple: Record = { @@ -219,25 +247,17 @@ function parseHeredocHeaders( else if (character === "'") frame.quote = 'none' continue } + const expansion = readShellExpansionStart( + code, + index, + frame.quote === 'none' && frame.kind !== 'arithmetic' + ) if (frame.quote === 'double') { if (character === '\\') index += 1 else if (character === '"') frame.quote = 'none' - else if (character === '$' && code[index + 1] === '(' && code[index + 2] === '(') { - frames.push({ - kind: 'arithmetic', - quote: 'none', - parenthesisDepth: 2, - literalRoot: false, - }) - index += 2 - } else if (character === '$' && code[index + 1] === '(') { - frames.push({ - kind: 'command', - quote: 'none', - parenthesisDepth: 1, - literalRoot: false, - }) - index += 1 + else if (expansion) { + frames.push(expansion.frame) + index += expansion.length - 1 } else if (character === '`') { frames.push({ kind: 'backtick', @@ -270,34 +290,9 @@ function parseHeredocHeaders( index += 1 continue } - if (character === '$' && code[index + 1] === '(' && code[index + 2] === '(') { - frames.push({ - kind: 'arithmetic', - quote: 'none', - parenthesisDepth: 2, - literalRoot: false, - }) - index += 2 - continue - } - if (shellArithmeticCommandStarts(code, index)) { - frames.push({ - kind: 'arithmetic', - quote: 'none', - parenthesisDepth: 2, - literalRoot: false, - }) - index += 1 - continue - } - if (character === '$' && code[index + 1] === '(') { - frames.push({ - kind: 'command', - quote: 'none', - parenthesisDepth: 1, - literalRoot: false, - }) - index += 1 + if (expansion) { + frames.push(expansion.frame) + index += expansion.length - 1 continue } if (character === '`') { @@ -309,6 +304,14 @@ function parseHeredocHeaders( }) continue } + if (frame.bracketDepth !== undefined) { + if (character === '[') frame.bracketDepth += 1 + if (character === ']') { + frame.bracketDepth -= 1 + if (frame.bracketDepth === 0) frames.pop() + } + continue + } if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === '(') { frame.parenthesisDepth += 1 continue @@ -388,6 +391,14 @@ function collectHeredocs(code: string): HeredocDeclaration[] { return declarations } +/** Delimiter words and complete bodies are data, not part of the enclosing shell's quote state. */ +function heredocExcludedRanges(heredocs: HeredocDeclaration[]): Array<[number, number]> { + return heredocs.flatMap((heredoc) => [ + [heredoc.operatorStart, heredoc.operatorEnd], + [heredoc.bodyStart, heredoc.removalEnd], + ]) +} + function shellExpansion(name: string, quote: ShellQuote): string { const expansion = `\${${name}}` if (quote === 'double') return expansion @@ -422,10 +433,11 @@ function isShellAssignmentName(code: string, occurrence: CodePlaceholderOccurren function getUnsupportedShellPosition( code: string, occurrence: CodePlaceholderOccurrence, - quote: ShellQuote + context: ShellOccurrenceContext ): string | undefined { + if (context.arithmetic) return 'in a shell arithmetic expression' if (code[occurrence.start - 1] === '$') return 'immediately after "$"' - if (quote !== 'none') return undefined + if (context.quote !== 'none') return undefined const lineStart = Math.max( code.lastIndexOf('\n', occurrence.start - 1), @@ -444,8 +456,10 @@ function collectShellOccurrenceContexts( occurrences: CodePlaceholderOccurrence[], start: number, end: number, - literalRoot: boolean + literalRoot: boolean, + excludedRanges: ReadonlyArray = [] ): Map { + const excludedEndByStart = new Map(excludedRanges) const occurrenceByStart = new Map( occurrences .filter((occurrence) => occurrence.start >= start && occurrence.end <= end) @@ -457,12 +471,20 @@ function collectShellOccurrenceContexts( ] for (let index = start; index < end; ) { + const excludedEnd = excludedEndByStart.get(index) + if (excludedEnd !== undefined) { + index = excludedEnd + continue + } const frame = frames.at(-1) if (!frame) break const occurrence = occurrenceByStart.get(index) if (occurrence) { - contexts.set(occurrence, { quote: frame.quote }) + contexts.set(occurrence, { + quote: frame.quote, + arithmetic: frames.some((candidate) => candidate.kind === 'arithmetic'), + }) index = occurrence.end continue } @@ -488,6 +510,11 @@ function collectShellOccurrenceContexts( } continue } + const expansion = readShellExpansionStart( + code, + index, + frame.quote === 'none' && !frame.literalRoot && frame.kind !== 'arithmetic' + ) if (frame.quote === 'double') { if (character === '\\') { const escaped = occurrenceByStart.get(index + 1) @@ -500,14 +527,9 @@ function collectShellOccurrenceContexts( } else if (character === '"') { frame.quote = 'none' index += 1 - } else if (character === '$' && code[index + 1] === '(') { - frames.push({ - kind: 'command', - quote: 'none', - parenthesisDepth: 1, - literalRoot: false, - }) - index += 2 + } else if (expansion) { + frames.push(expansion.frame) + index += expansion.length } else if (character === '`') { frames.push({ kind: 'backtick', @@ -557,14 +579,9 @@ function collectShellOccurrenceContexts( index += 1 continue } - if (character === '$' && code[index + 1] === '(') { - frames.push({ - kind: 'command', - quote: 'none', - parenthesisDepth: 1, - literalRoot: false, - }) - index += 2 + if (expansion) { + frames.push(expansion.frame) + index += expansion.length continue } if (character === '`') { @@ -577,12 +594,21 @@ function collectShellOccurrenceContexts( index += 1 continue } - if (frame.kind === 'command' && character === '(') { + if (frame.bracketDepth !== undefined) { + if (character === '[') frame.bracketDepth += 1 + if (character === ']') { + frame.bracketDepth -= 1 + if (frame.bracketDepth === 0) frames.pop() + } + index += 1 + continue + } + if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === '(') { frame.parenthesisDepth += 1 index += 1 continue } - if (frame.kind === 'command' && character === ')') { + if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === ')') { frame.parenthesisDepth -= 1 if (frame.parenthesisDepth === 0) frames.pop() index += 1 @@ -635,37 +661,39 @@ function recordShellDirectEnvironmentReads( } if (matches.length === 0) return - /** - * A heredoc with a quoted delimiter (`<<'EOF'`) is literal, so nothing in its body expands. - * The frame scanner below models quoting within a line, not heredoc bodies, so those are - * excluded up front — otherwise a `$NAME` printed verbatim would be reported as a read that - * never happened, and a usage trail must not claim uses that did not occur. - */ - const literalHeredocBodies = collectHeredocs(code) - .filter((heredoc) => heredoc.quoted) - .map((heredoc): [number, number] => [heredoc.bodyStart, heredoc.bodyEnd]) - - const candidates: CodePlaceholderOccurrence[] = [] - for (const candidate of matches) { - if (isOffsetInRanges(candidate.index, literalHeredocBodies)) continue - candidates.push({ + const heredocs = collectHeredocs(code) + const candidates = matches.map( + (candidate): CodePlaceholderOccurrence => ({ start: candidate.index, end: candidate.index + candidate[0].length, raw: candidate[0], name: (candidate[1] ?? candidate[2]) as string, }) + ) + const contexts = collectShellOccurrenceContexts( + code, + candidates, + 0, + code.length, + false, + heredocExcludedRanges(heredocs) + ) + /** Unquoted bodies expand parameters, but prose quotes and comment markers remain literal. */ + for (const heredoc of heredocs) { + if (heredoc.quoted) continue + const bodyContexts = collectShellOccurrenceContexts( + code, + candidates, + heredoc.bodyStart, + heredoc.bodyEnd, + true + ) + for (const [candidate, shellContext] of bodyContexts) contexts.set(candidate, shellContext) } - if (candidates.length === 0) return - const contexts = collectShellOccurrenceContexts(code, candidates, 0, code.length, false) for (const candidate of candidates) { const shellContext = contexts.get(candidate) - /** - * No context means the scanner never reached this offset — it skipped the region as a - * comment. Absence is therefore evidence the expansion does not run, not permission to - * record it, so this reads as an allowlist rather than a denylist. Single quotes suppress - * expansion outright. - */ + /** Comments, delimiter words, literal bodies and single-quoted strings do not expand. */ if (!shellContext || shellContext.quote === 'single') continue context.recordDirectEnvironmentRead(candidate.name, candidate.start) } @@ -699,12 +727,9 @@ export async function compileShellPlaceholders( const heredocs = collectHeredocs(input.code) const shellOccurrences = context.occurrences.filter(isLegacyShellPlaceholder) const edits: SourceEdit[] = [] - const excludedRanges: Array<[number, number]> = [] + const excludedRanges = heredocExcludedRanges(heredocs) for (const heredoc of heredocs) { - excludedRanges.push([heredoc.operatorStart, heredoc.operatorEnd]) - excludedRanges.push([heredoc.bodyStart, heredoc.removalEnd]) - const delimiterOccurrences = shellOccurrences.filter( (occurrence) => occurrence.start >= heredoc.operatorStart && occurrence.end <= heredoc.operatorEnd @@ -795,7 +820,7 @@ export async function compileShellPlaceholders( const unsupportedPosition = getUnsupportedShellPosition( input.code, occurrence, - occurrenceContext.quote + occurrenceContext ) if (unsupportedPosition) { if (context.hasValue(occurrence.name)) { @@ -828,7 +853,8 @@ export async function compileShellPlaceholders( rootOccurrences, 0, input.code.length, - false + false, + excludedRanges ) for (const occurrence of rootOccurrences) { const occurrenceContext = rootContexts.get(occurrence) @@ -850,7 +876,7 @@ export async function compileShellPlaceholders( const unsupportedPosition = getUnsupportedShellPosition( input.code, occurrence, - occurrenceContext.quote + occurrenceContext ) if (unsupportedPosition) { if (context.hasValue(occurrence.name)) { From d2131688d02d7a141a18b3840750398d047eaee4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 14:45:24 -0700 Subject: [PATCH 2/7] fix(shell): reject arithmetic array subscript placeholders --- .../code-placeholders/compiler.test.ts | 28 +++++++ .../lib/execution/code-placeholders/shell.ts | 83 +++++++++++++++++-- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/execution/code-placeholders/compiler.test.ts b/apps/sim/lib/execution/code-placeholders/compiler.test.ts index fa3658ba32b..81ebdc05e52 100644 --- a/apps/sim/lib/execution/code-placeholders/compiler.test.ts +++ b/apps/sim/lib/execution/code-placeholders/compiler.test.ts @@ -1026,6 +1026,15 @@ describe('code placeholder compiler', () => { 'echo "$[ values[0] + {{KEY}} ]"', 'cat < { await expect( compileCodePlaceholders({ @@ -1064,6 +1073,25 @@ describe('code placeholder compiler', () => { ) }) + it('keeps bracket words and array values outside arithmetic positions', async () => { + const compiled = await compileCodePlaceholders({ + code: [ + 'printf "%s\\n" "values[{{KEY}}]=literal" values[{{KEY}}]', + 'values=("{{KEY}}" item[{{KEY}}])', + 'values[0]="{{KEY}}"', + `printf "%s\\n" "\${values[@]}"`, + 'cat </dev/null\nToday's report\nEOF"], ['quoted', "cat <<'EOF' >/dev/null\nToday's report\nEOF"], diff --git a/apps/sim/lib/execution/code-placeholders/shell.ts b/apps/sim/lib/execution/code-placeholders/shell.ts index 479fe400b8c..2d8c4322adb 100644 --- a/apps/sim/lib/execution/code-placeholders/shell.ts +++ b/apps/sim/lib/execution/code-placeholders/shell.ts @@ -26,17 +26,22 @@ interface HeredocDeclaration { type ShellQuote = 'none' | 'single' | 'double' | 'ansi' interface ShellScanFrame { - kind: 'root' | 'command' | 'arithmetic' | 'backtick' + kind: 'root' | 'command' | 'arithmetic' | 'backtick' | 'array' quote: ShellQuote parenthesisDepth: number bracketDepth?: number + /** A bare name[index] is arithmetic only when its closing bracket is followed by assignment. */ + arrayAssignment?: boolean literalRoot: boolean } interface ShellOccurrenceContext { quote: ShellQuote - /** Enclosing arithmetic also re-evaluates the output of a nested command substitution. */ - arithmetic?: boolean + /** + * Enclosing arithmetic also re-evaluates nested substitutions. Keep frame references so a + * bare subscript can be classified when its closing bracket confirms an assignment. + */ + arithmeticFrames?: ShellScanFrame[] unsupported?: 'escaped sequence' } @@ -98,6 +103,49 @@ function readShellExpansionStart( } } +function readShellArrayStart( + code: string, + index: number, + parent: ShellScanFrame +): ShellScanFrame | undefined { + const character = code[index] + if (character !== '[' && character !== '(') return undefined + + let nameEnd = index + if (character === '(') { + if (code[index - 1] !== '=') return undefined + nameEnd -= 1 + if (code[nameEnd - 1] === '+') nameEnd -= 1 + } + let nameStart = nameEnd + while (nameStart > 0 && /[A-Za-z0-9_]/.test(code[nameStart - 1])) nameStart -= 1 + const hasName = nameStart < nameEnd && /[A-Za-z_]/.test(code[nameStart]) + const parameterStart = + code[nameStart - 1] === '#' || code[nameStart - 1] === '!' ? nameStart - 3 : nameStart - 2 + const parameter = + character === '[' && + hasName && + code.slice(parameterStart, parameterStart + 2) === '${' && + !isBackslashEscaped(code, parameterStart) + const wordStart = nameStart === 0 || /\s|[;&|()]/.test(code[nameStart - 1]) + const assignment = + parent.quote === 'none' && + !parent.literalRoot && + wordStart && + (hasName || (character === '[' && parent.kind === 'array')) + if (!parameter && !assignment) return undefined + + return { + kind: character === '[' ? 'arithmetic' : 'array', + quote: 'none', + parenthesisDepth: character === '(' ? 1 : 0, + ...(character === '[' + ? { bracketDepth: 1, ...(!parameter ? { arrayAssignment: false } : {}) } + : {}), + literalRoot: false, + } +} + function decodeAnsiCCharacter(code: string, index: number): { value: string; end: number } { const character = code[index] const simple: Record = { @@ -435,7 +483,9 @@ function getUnsupportedShellPosition( occurrence: CodePlaceholderOccurrence, context: ShellOccurrenceContext ): string | undefined { - if (context.arithmetic) return 'in a shell arithmetic expression' + if (context.arithmeticFrames?.some((frame) => frame.arrayAssignment !== false)) { + return 'in a shell arithmetic expression' + } if (code[occurrence.start - 1] === '$') return 'immediately after "$"' if (context.quote !== 'none') return undefined @@ -483,7 +533,7 @@ function collectShellOccurrenceContexts( if (occurrence) { contexts.set(occurrence, { quote: frame.quote, - arithmetic: frames.some((candidate) => candidate.kind === 'arithmetic'), + arithmeticFrames: frames.filter((candidate) => candidate.kind === 'arithmetic'), }) index = occurrence.end continue @@ -515,6 +565,12 @@ function collectShellOccurrenceContexts( index, frame.quote === 'none' && !frame.literalRoot && frame.kind !== 'arithmetic' ) + const array = readShellArrayStart(code, index, frame) + if (array) { + frames.push(array) + index += 1 + continue + } if (frame.quote === 'double') { if (character === '\\') { const escaped = occurrenceByStart.get(index + 1) @@ -598,17 +654,28 @@ function collectShellOccurrenceContexts( if (character === '[') frame.bracketDepth += 1 if (character === ']') { frame.bracketDepth -= 1 - if (frame.bracketDepth === 0) frames.pop() + if (frame.bracketDepth === 0) { + if (frame.arrayAssignment !== undefined) { + frame.arrayAssignment = code[index + 1] === '=' || code.startsWith('+=', index + 1) + } + frames.pop() + } } index += 1 continue } - if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === '(') { + if ( + (frame.kind === 'command' || frame.kind === 'arithmetic' || frame.kind === 'array') && + character === '(' + ) { frame.parenthesisDepth += 1 index += 1 continue } - if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === ')') { + if ( + (frame.kind === 'command' || frame.kind === 'arithmetic' || frame.kind === 'array') && + character === ')' + ) { frame.parenthesisDepth -= 1 if (frame.parenthesisDepth === 0) frames.pop() index += 1 From c4deb37c551ba803f3106231957981e44ec06891 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 14:50:20 -0700 Subject: [PATCH 3/7] fix(shell): share nested arithmetic context ancestry --- .../lib/execution/code-placeholders/shell.ts | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/execution/code-placeholders/shell.ts b/apps/sim/lib/execution/code-placeholders/shell.ts index 2d8c4322adb..def1e810f06 100644 --- a/apps/sim/lib/execution/code-placeholders/shell.ts +++ b/apps/sim/lib/execution/code-placeholders/shell.ts @@ -32,16 +32,17 @@ interface ShellScanFrame { bracketDepth?: number /** A bare name[index] is arithmetic only when its closing bracket is followed by assignment. */ arrayAssignment?: boolean + arithmeticParent?: ShellScanFrame literalRoot: boolean } interface ShellOccurrenceContext { quote: ShellQuote /** - * Enclosing arithmetic also re-evaluates nested substitutions. Keep frame references so a + * Enclosing arithmetic also re-evaluates nested substitutions. Keep a frame reference so a * bare subscript can be classified when its closing bracket confirms an assignment. */ - arithmeticFrames?: ShellScanFrame[] + arithmeticFrame?: ShellScanFrame unsupported?: 'escaped sequence' } @@ -78,6 +79,12 @@ function shellArithmeticCommandStarts(code: string, index: number): boolean { return previous === undefined || /\s|[;&|()<>]/.test(previous) } +function pushShellFrame(frames: ShellScanFrame[], frame: ShellScanFrame): void { + const parent = frames.at(-1) + frame.arithmeticParent = parent?.kind === 'arithmetic' ? parent : parent?.arithmeticParent + frames.push(frame) +} + function readShellExpansionStart( code: string, index: number, @@ -304,10 +311,10 @@ function parseHeredocHeaders( if (character === '\\') index += 1 else if (character === '"') frame.quote = 'none' else if (expansion) { - frames.push(expansion.frame) + pushShellFrame(frames, expansion.frame) index += expansion.length - 1 } else if (character === '`') { - frames.push({ + pushShellFrame(frames, { kind: 'backtick', quote: 'none', parenthesisDepth: 0, @@ -339,12 +346,12 @@ function parseHeredocHeaders( continue } if (expansion) { - frames.push(expansion.frame) + pushShellFrame(frames, expansion.frame) index += expansion.length - 1 continue } if (character === '`') { - frames.push({ + pushShellFrame(frames, { kind: 'backtick', quote: 'none', parenthesisDepth: 0, @@ -483,8 +490,8 @@ function getUnsupportedShellPosition( occurrence: CodePlaceholderOccurrence, context: ShellOccurrenceContext ): string | undefined { - if (context.arithmeticFrames?.some((frame) => frame.arrayAssignment !== false)) { - return 'in a shell arithmetic expression' + for (let frame = context.arithmeticFrame; frame; frame = frame.arithmeticParent) { + if (frame.arrayAssignment !== false) return 'in a shell arithmetic expression' } if (code[occurrence.start - 1] === '$') return 'immediately after "$"' if (context.quote !== 'none') return undefined @@ -533,7 +540,7 @@ function collectShellOccurrenceContexts( if (occurrence) { contexts.set(occurrence, { quote: frame.quote, - arithmeticFrames: frames.filter((candidate) => candidate.kind === 'arithmetic'), + arithmeticFrame: frame.kind === 'arithmetic' ? frame : frame.arithmeticParent, }) index = occurrence.end continue @@ -567,7 +574,7 @@ function collectShellOccurrenceContexts( ) const array = readShellArrayStart(code, index, frame) if (array) { - frames.push(array) + pushShellFrame(frames, array) index += 1 continue } @@ -584,10 +591,10 @@ function collectShellOccurrenceContexts( frame.quote = 'none' index += 1 } else if (expansion) { - frames.push(expansion.frame) + pushShellFrame(frames, expansion.frame) index += expansion.length } else if (character === '`') { - frames.push({ + pushShellFrame(frames, { kind: 'backtick', quote: 'none', parenthesisDepth: 0, @@ -636,12 +643,12 @@ function collectShellOccurrenceContexts( continue } if (expansion) { - frames.push(expansion.frame) + pushShellFrame(frames, expansion.frame) index += expansion.length continue } if (character === '`') { - frames.push({ + pushShellFrame(frames, { kind: 'backtick', quote: 'none', parenthesisDepth: 0, From 449d127d43305873d950a38686bb0f02d1a20a36 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 15:04:14 -0700 Subject: [PATCH 4/7] fix(shell): distinguish array assignments from command arguments --- .../code-placeholders/compiler.test.ts | 19 +++++++ .../lib/execution/code-placeholders/shell.ts | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/apps/sim/lib/execution/code-placeholders/compiler.test.ts b/apps/sim/lib/execution/code-placeholders/compiler.test.ts index 81ebdc05e52..8498ab3d580 100644 --- a/apps/sim/lib/execution/code-placeholders/compiler.test.ts +++ b/apps/sim/lib/execution/code-placeholders/compiler.test.ts @@ -1028,6 +1028,10 @@ describe('code placeholder compiler', () => { 'cat < { ) }) + it('keeps array-shaped command arguments literal', async () => { + const compiled = await compileCodePlaceholders({ + code: [ + 'printf "%s\\n" config[{{KEY}}]=1', + 'PREFIX=1 printf "%s\\n" config[{{KEY}}]+=2', + 'if true; then printf "%s\\n" config[{{KEY}}]=3; fi', + ].join('\n'), + language: CodeLanguage.Shell, + environmentVariables: { KEY: 'word' }, + }) + expect(executeShell(compiled.code, compiled.bindings)).toBe( + 'config[word]=1\nconfig[word]+=2\nconfig[word]=3\n' + ) + }) + it.each([ ['unquoted', "cat </dev/null\nToday's report\nEOF"], ['quoted', "cat <<'EOF' >/dev/null\nToday's report\nEOF"], diff --git a/apps/sim/lib/execution/code-placeholders/shell.ts b/apps/sim/lib/execution/code-placeholders/shell.ts index def1e810f06..4342192cec6 100644 --- a/apps/sim/lib/execution/code-placeholders/shell.ts +++ b/apps/sim/lib/execution/code-placeholders/shell.ts @@ -33,6 +33,10 @@ interface ShellScanFrame { /** A bare name[index] is arithmetic only when its closing bracket is followed by assignment. */ arrayAssignment?: boolean arithmeticParent?: ShellScanFrame + wordStart?: number + commandPosition?: boolean + declarationCommand?: boolean + redirectionTarget?: boolean literalRoot: boolean } @@ -139,6 +143,7 @@ function readShellArrayStart( parent.quote === 'none' && !parent.literalRoot && wordStart && + (parent.kind === 'array' || parent.commandPosition !== false || parent.declarationCommand) && (hasName || (character === '[' && parent.kind === 'array')) if (!parameter && !assignment) return undefined @@ -282,6 +287,52 @@ function readHeredocDelimiterWord( return consumed ? { delimiter, quoted, end: cursor } : undefined } +/** Assignment-shaped words after an ordinary command name are arguments, not arithmetic. */ +function trackShellCommandPosition(code: string, index: number, frame: ShellScanFrame): void { + if ( + frame.literalRoot || + frame.quote !== 'none' || + (frame.kind !== 'root' && frame.kind !== 'command' && frame.kind !== 'backtick') + ) { + return + } + const character = code[index] + const redirection = character === '<' || character === '>' || code.startsWith('&>', index) + const separator = + /[;\n&|()]/.test(character) && + !redirection && + !((character === '&' || character === '|') && /[<>]/.test(code[index - 1] ?? '')) + if (!separator && !redirection && !/\s/.test(character)) { + if (character === '\\' && /^\r?\n/.test(code.slice(index + 1, index + 3))) return + frame.wordStart ??= index + return + } + + if (frame.wordStart !== undefined) { + const word = code.slice(frame.wordStart, index).replace(/\\\r?\n/g, '') + if (frame.redirectionTarget) { + frame.redirectionTarget = false + } else if ( + !(redirection && /^\d+$/.test(word)) && + frame.commandPosition !== false && + !/^[A-Za-z_][A-Za-z0-9_]*(?:\+?=|\[[\s\S]*\]\+?=)/.test(word) && + !/^(?:if|then|else|elif|while|until|do|!|\{|time|command|builtin|exec)$/.test(word) + ) { + frame.commandPosition = false + const name = readHeredocDelimiterWord(word, 0, word.length)?.delimiter + frame.declarationCommand = /^(?:declare|export|local|readonly|typeset)$/.test(name ?? '') + } + frame.wordStart = undefined + } + if (separator) { + frame.commandPosition = true + frame.declarationCommand = false + frame.redirectionTarget = false + } else if (redirection) { + frame.redirectionTarget = true + } +} + function parseHeredocHeaders( code: string, lineStart: number, @@ -536,6 +587,7 @@ function collectShellOccurrenceContexts( const frame = frames.at(-1) if (!frame) break + trackShellCommandPosition(code, index, frame) const occurrence = occurrenceByStart.get(index) if (occurrence) { contexts.set(occurrence, { @@ -614,6 +666,7 @@ function collectShellOccurrenceContexts( } if (!frame.literalRoot && shellCommentStarts(code, index)) { const newline = code.indexOf('\n', index) + if (newline !== -1 && newline < end) trackShellCommandPosition(code, newline, frame) index = newline === -1 || newline >= end ? end : newline + 1 continue } From a9c27d854ccd0cdc1b50027b6770143d6943291d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 15:15:17 -0700 Subject: [PATCH 5/7] fix(shell): reject placeholders in numeric conditional operands --- .../code-placeholders/compiler.test.ts | 19 ++++ .../lib/execution/code-placeholders/shell.ts | 101 ++++++++++++++++-- 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/execution/code-placeholders/compiler.test.ts b/apps/sim/lib/execution/code-placeholders/compiler.test.ts index 8498ab3d580..f48a6a4b977 100644 --- a/apps/sim/lib/execution/code-placeholders/compiler.test.ts +++ b/apps/sim/lib/execution/code-placeholders/compiler.test.ts @@ -1032,6 +1032,13 @@ describe('code placeholder compiler', () => { 'if values[{{KEY}}]=x; then :; fi', 'declare -a values[{{KEY}}]=x', 'printf %s done # end of command\nvalues[{{KEY}}]=x', + '[[ 1 -eq {{KEY}} ]]', + '[[ "{{KEY}}" -ne 1 ]]', + '[[ 1 -lt "{{KEY}}" ]]', + '[[ {{KEY}} -le 1 ]]', + '[[ 1 -gt {{KEY}} ]]', + '[[ {{KEY}} -ge 1 ]]', + '[[ "$(printf %s "{{KEY}}")" -eq 1 ]]', 'values[ 1 + {{KEY}} ]=x', 'values[$(printf %s "{{KEY}}")]=x', 'values=([{{KEY}}]=x)', @@ -1111,6 +1118,18 @@ describe('code placeholder compiler', () => { ) }) + it('preserves string operands beside numeric shell comparisons', async () => { + const compiled = await compileCodePlaceholders({ + code: [ + '[[ "{{KEY}}" == word && 1 -eq 1 ]] && printf "%s\\n" "{{KEY}}"', + '[[ 2 -gt 1 && ( "{{KEY}}" == word || "{{KEY}}" == "-eq" ) ]] && printf "%s\\n" "{{KEY}}"', + ].join('\n'), + language: CodeLanguage.Shell, + environmentVariables: { KEY: 'word' }, + }) + expect(executeShell(compiled.code, compiled.bindings)).toBe('word\nword\n') + }) + it.each([ ['unquoted', "cat </dev/null\nToday's report\nEOF"], ['quoted', "cat <<'EOF' >/dev/null\nToday's report\nEOF"], diff --git a/apps/sim/lib/execution/code-placeholders/shell.ts b/apps/sim/lib/execution/code-placeholders/shell.ts index 4342192cec6..adef72c2579 100644 --- a/apps/sim/lib/execution/code-placeholders/shell.ts +++ b/apps/sim/lib/execution/code-placeholders/shell.ts @@ -26,13 +26,19 @@ interface HeredocDeclaration { type ShellQuote = 'none' | 'single' | 'double' | 'ansi' interface ShellScanFrame { - kind: 'root' | 'command' | 'arithmetic' | 'backtick' | 'array' + kind: 'root' | 'command' | 'arithmetic' | 'backtick' | 'array' | 'conditional' quote: ShellQuote parenthesisDepth: number bracketDepth?: number - /** A bare name[index] is arithmetic only when its closing bracket is followed by assignment. */ - arrayAssignment?: boolean + /** Some operands become arithmetic only after a following assignment or comparison operator. */ + arithmeticEnabled?: boolean arithmeticParent?: ShellScanFrame + conditional?: { + parent?: ShellScanFrame + previousOperand?: ShellScanFrame + word?: ShellScanFrame + numericOperand: boolean + } wordStart?: number commandPosition?: boolean declarationCommand?: boolean @@ -142,6 +148,7 @@ function readShellArrayStart( const assignment = parent.quote === 'none' && !parent.literalRoot && + parent.kind !== 'conditional' && wordStart && (parent.kind === 'array' || parent.commandPosition !== false || parent.declarationCommand) && (hasName || (character === '[' && parent.kind === 'array')) @@ -152,7 +159,7 @@ function readShellArrayStart( quote: 'none', parenthesisDepth: character === '(' ? 1 : 0, ...(character === '[' - ? { bracketDepth: 1, ...(!parameter ? { arrayAssignment: false } : {}) } + ? { bracketDepth: 1, ...(!parameter ? { arithmeticEnabled: false } : {}) } : {}), literalRoot: false, } @@ -333,6 +340,52 @@ function trackShellCommandPosition(code: string, index: number, frame: ShellScan } } +/** Keep operand contexts pending until the following conditional operator is known. */ +function trackShellConditionalOperand(code: string, index: number, frame: ShellScanFrame): void { + const conditional = frame.conditional + if (!conditional || frame.quote !== 'none') return + const character = code[index] + const closing = + frame.wordStart === undefined && + code.startsWith(']]', index) && + (index + 2 === code.length || /\s|[;&|()<>]/.test(code[index + 2])) + const separator = /[&|()]/.test(character) || closing + if (!separator && !/\s/.test(character)) { + if (character === '\\' && /^\r?\n/.test(code.slice(index + 1, index + 3))) return + if (frame.wordStart === undefined) { + frame.wordStart = index + conditional.word = { + kind: 'arithmetic', + quote: 'none', + parenthesisDepth: 0, + literalRoot: false, + arithmeticEnabled: conditional.numericOperand, + arithmeticParent: conditional.parent, + } + conditional.numericOperand = false + frame.arithmeticParent = conditional.word + } + return + } + + if (frame.wordStart !== undefined) { + const word = code.slice(frame.wordStart, index).replace(/\\\r?\n/g, '') + if (/^-(?:eq|ne|lt|le|gt|ge)$/.test(word)) { + if (conditional.previousOperand) conditional.previousOperand.arithmeticEnabled = true + conditional.numericOperand = true + } else { + conditional.previousOperand = conditional.word + } + frame.wordStart = undefined + conditional.word = undefined + frame.arithmeticParent = conditional.parent + } + if (separator) { + conditional.previousOperand = undefined + conditional.numericOperand = false + } +} + function parseHeredocHeaders( code: string, lineStart: number, @@ -542,7 +595,7 @@ function getUnsupportedShellPosition( context: ShellOccurrenceContext ): string | undefined { for (let frame = context.arithmeticFrame; frame; frame = frame.arithmeticParent) { - if (frame.arrayAssignment !== false) return 'in a shell arithmetic expression' + if (frame.arithmeticEnabled !== false) return 'in a shell arithmetic expression' } if (code[occurrence.start - 1] === '$') return 'immediately after "$"' if (context.quote !== 'none') return undefined @@ -588,6 +641,7 @@ function collectShellOccurrenceContexts( if (!frame) break trackShellCommandPosition(code, index, frame) + trackShellConditionalOperand(code, index, frame) const occurrence = occurrenceByStart.get(index) if (occurrence) { contexts.set(occurrence, { @@ -622,7 +676,10 @@ function collectShellOccurrenceContexts( const expansion = readShellExpansionStart( code, index, - frame.quote === 'none' && !frame.literalRoot && frame.kind !== 'arithmetic' + frame.quote === 'none' && + !frame.literalRoot && + frame.kind !== 'arithmetic' && + frame.kind !== 'conditional' ) const array = readShellArrayStart(code, index, frame) if (array) { @@ -664,6 +721,34 @@ function collectShellOccurrenceContexts( index += 1 continue } + if ( + frame.kind === 'conditional' && + frame.wordStart === undefined && + code.startsWith(']]', index) + ) { + frames.pop() + index += 2 + continue + } + if ( + !frame.literalRoot && + frame.commandPosition !== false && + code.startsWith('[[', index) && + (index === 0 || /\s|[;&|()]/.test(code[index - 1])) && + (index + 2 === end || /\s|[()]/.test(code[index + 2])) + ) { + const conditional: ShellScanFrame = { + kind: 'conditional', + quote: 'none', + parenthesisDepth: 0, + literalRoot: false, + conditional: { numericOperand: false }, + } + pushShellFrame(frames, conditional) + if (conditional.conditional) conditional.conditional.parent = conditional.arithmeticParent + index += 2 + continue + } if (!frame.literalRoot && shellCommentStarts(code, index)) { const newline = code.indexOf('\n', index) if (newline !== -1 && newline < end) trackShellCommandPosition(code, newline, frame) @@ -715,8 +800,8 @@ function collectShellOccurrenceContexts( if (character === ']') { frame.bracketDepth -= 1 if (frame.bracketDepth === 0) { - if (frame.arrayAssignment !== undefined) { - frame.arrayAssignment = code[index + 1] === '=' || code.startsWith('+=', index + 1) + if (frame.arithmeticEnabled !== undefined) { + frame.arithmeticEnabled = code[index + 1] === '=' || code.startsWith('+=', index + 1) } frames.pop() } From bc172c04efa71c2fcd80a9572ac5c43871422faf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 15:22:10 -0700 Subject: [PATCH 6/7] fix(shell): reject arithmetic placeholders passed to let --- .../code-placeholders/compiler.test.ts | 17 ++++++++++++++ .../lib/execution/code-placeholders/shell.ts | 22 ++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/execution/code-placeholders/compiler.test.ts b/apps/sim/lib/execution/code-placeholders/compiler.test.ts index f48a6a4b977..9ff91cf063f 100644 --- a/apps/sim/lib/execution/code-placeholders/compiler.test.ts +++ b/apps/sim/lib/execution/code-placeholders/compiler.test.ts @@ -1039,6 +1039,10 @@ describe('code placeholder compiler', () => { '[[ 1 -gt {{KEY}} ]]', '[[ {{KEY}} -ge 1 ]]', '[[ "$(printf %s "{{KEY}}")" -eq 1 ]]', + 'let "x={{KEY}}"', + 'let values[{{KEY}}]=1', + "builtin let 'x={{KEY}}'", + 'let "x=$(printf %s "{{KEY}}")"', 'values[ 1 + {{KEY}} ]=x', 'values[$(printf %s "{{KEY}}")]=x', 'values=([{{KEY}}]=x)', @@ -1130,6 +1134,19 @@ describe('code placeholder compiler', () => { expect(executeShell(compiled.code, compiled.bindings)).toBe('word\nword\n') }) + it('ends builtin arithmetic context at the next shell command', async () => { + const compiled = await compileCodePlaceholders({ + code: [ + 'let "x=1"; printf "%s\\n" "{{KEY}}"', + 'let "x=2" # end of arithmetic', + 'printf "%s\\n" "{{KEY}}"', + ].join('\n'), + language: CodeLanguage.Shell, + environmentVariables: { KEY: 'two words' }, + }) + expect(executeShell(compiled.code, compiled.bindings)).toBe('two words\ntwo words\n') + }) + it.each([ ['unquoted', "cat </dev/null\nToday's report\nEOF"], ['quoted', "cat <<'EOF' >/dev/null\nToday's report\nEOF"], diff --git a/apps/sim/lib/execution/code-placeholders/shell.ts b/apps/sim/lib/execution/code-placeholders/shell.ts index adef72c2579..4e282443d6d 100644 --- a/apps/sim/lib/execution/code-placeholders/shell.ts +++ b/apps/sim/lib/execution/code-placeholders/shell.ts @@ -33,6 +33,7 @@ interface ShellScanFrame { /** Some operands become arithmetic only after a following assignment or comparison operator. */ arithmeticEnabled?: boolean arithmeticParent?: ShellScanFrame + commandArithmetic?: ShellScanFrame conditional?: { parent?: ShellScanFrame previousOperand?: ShellScanFrame @@ -89,9 +90,14 @@ function shellArithmeticCommandStarts(code: string, index: number): boolean { return previous === undefined || /\s|[;&|()<>]/.test(previous) } +function getShellArithmeticFrame(frame: ShellScanFrame | undefined): ShellScanFrame | undefined { + return frame?.kind === 'arithmetic' + ? frame + : (frame?.commandArithmetic ?? frame?.arithmeticParent) +} + function pushShellFrame(frames: ShellScanFrame[], frame: ShellScanFrame): void { - const parent = frames.at(-1) - frame.arithmeticParent = parent?.kind === 'arithmetic' ? parent : parent?.arithmeticParent + frame.arithmeticParent = getShellArithmeticFrame(frames.at(-1)) frames.push(frame) } @@ -328,12 +334,22 @@ function trackShellCommandPosition(code: string, index: number, frame: ShellScan frame.commandPosition = false const name = readHeredocDelimiterWord(word, 0, word.length)?.delimiter frame.declarationCommand = /^(?:declare|export|local|readonly|typeset)$/.test(name ?? '') + if (name === 'let') { + frame.commandArithmetic = { + kind: 'arithmetic', + quote: 'none', + parenthesisDepth: 0, + literalRoot: false, + arithmeticParent: frame.arithmeticParent, + } + } } frame.wordStart = undefined } if (separator) { frame.commandPosition = true frame.declarationCommand = false + frame.commandArithmetic = undefined frame.redirectionTarget = false } else if (redirection) { frame.redirectionTarget = true @@ -646,7 +662,7 @@ function collectShellOccurrenceContexts( if (occurrence) { contexts.set(occurrence, { quote: frame.quote, - arithmeticFrame: frame.kind === 'arithmetic' ? frame : frame.arithmeticParent, + arithmeticFrame: getShellArithmeticFrame(frame), }) index = occurrence.end continue From 29d957626bf78e5bcb45e33ee7e2aa1bddc4dfa0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 15:28:11 -0700 Subject: [PATCH 7/7] fix(shell): preserve placeholders in let redirection paths --- .../execution/code-placeholders/compiler.test.ts | 14 ++++++++++++++ apps/sim/lib/execution/code-placeholders/shell.ts | 6 +++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/execution/code-placeholders/compiler.test.ts b/apps/sim/lib/execution/code-placeholders/compiler.test.ts index 9ff91cf063f..ae5cb6584ba 100644 --- a/apps/sim/lib/execution/code-placeholders/compiler.test.ts +++ b/apps/sim/lib/execution/code-placeholders/compiler.test.ts @@ -1043,6 +1043,8 @@ describe('code placeholder compiler', () => { 'let values[{{KEY}}]=1', "builtin let 'x={{KEY}}'", 'let "x=$(printf %s "{{KEY}}")"', + 'let >/dev/null "x={{KEY}}"', + 'echo "$(( $(let "x=1" > /dev/{{KEY}}; printf 1) ))"', 'values[ 1 + {{KEY}} ]=x', 'values[$(printf %s "{{KEY}}")]=x', 'values=([{{KEY}}]=x)', @@ -1147,6 +1149,18 @@ describe('code placeholder compiler', () => { expect(executeShell(compiled.code, compiled.bindings)).toBe('two words\ntwo words\n') }) + it.each(['> /dev/{{KEY}}', '> "/dev/{{KEY}}"', '> "$(printf /dev/%s "{{KEY}}")"'])( + 'keeps let redirection targets out of arithmetic context: %s', + async (redirect) => { + const compiled = await compileCodePlaceholders({ + code: `let "x=1" ${redirect}; printf "%s\\n" "$x"`, + language: CodeLanguage.Shell, + environmentVariables: { KEY: 'null' }, + }) + expect(executeShell(compiled.code, compiled.bindings)).toBe('1\n') + } + ) + it.each([ ['unquoted', "cat </dev/null\nToday's report\nEOF"], ['quoted', "cat <<'EOF' >/dev/null\nToday's report\nEOF"], diff --git a/apps/sim/lib/execution/code-placeholders/shell.ts b/apps/sim/lib/execution/code-placeholders/shell.ts index 4e282443d6d..bcb404dd7ca 100644 --- a/apps/sim/lib/execution/code-placeholders/shell.ts +++ b/apps/sim/lib/execution/code-placeholders/shell.ts @@ -91,9 +91,9 @@ function shellArithmeticCommandStarts(code: string, index: number): boolean { } function getShellArithmeticFrame(frame: ShellScanFrame | undefined): ShellScanFrame | undefined { - return frame?.kind === 'arithmetic' - ? frame - : (frame?.commandArithmetic ?? frame?.arithmeticParent) + if (frame?.kind === 'arithmetic') return frame + if (!frame?.redirectionTarget && frame?.commandArithmetic) return frame.commandArithmetic + return frame?.arithmeticParent } function pushShellFrame(frames: ShellScanFrame[], frame: ShellScanFrame): void {