Skip to content

Commit f67222e

Browse files
authored
fix(code-placeholders): skip heredoc bodies when scanning shell quote context (#8603)
1 parent 4c6be10 commit f67222e

2 files changed

Lines changed: 92 additions & 22 deletions

File tree

‎apps/sim/lib/execution/code-placeholders/compiler.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,26 @@ describe('code placeholder compiler', () => {
885885
expect(executeShell(compiled.code, compiled.bindings)).toBe('<secret-value>\n')
886886
})
887887

888+
it('keeps shell quote context after heredoc bodies with unbalanced quotes', async () => {
889+
const compiled = await compileCodePlaceholders({
890+
code: [
891+
'cat <<EOF',
892+
"Today's report",
893+
'EOF',
894+
"cat <<'EOF'",
895+
'a "quote',
896+
'EOF',
897+
'printf "<%s>\\n" "{{KEY}}"',
898+
].join('\n'),
899+
language: CodeLanguage.Shell,
900+
environmentVariables: { KEY: ' * ' },
901+
})
902+
903+
expect(executeShell(compiled.code, compiled.bindings)).toBe(
904+
'Today\'s report\na "quote\n< * >\n'
905+
)
906+
})
907+
888908
it('tracks nested shell command substitutions and their own quote contexts', async () => {
889909
const compiled = await compileCodePlaceholders({
890910
code: [
@@ -1266,6 +1286,23 @@ describe('direct environment reads in shell', () => {
12661286
expect(unquoted.resolvedSecretNames).toEqual(['API_KEY'])
12671287
})
12681288

1289+
it('reports reads after heredoc bodies with unbalanced quotes', async () => {
1290+
for (const header of ['cat <<EOF', "cat <<'EOF'"]) {
1291+
const compiled = await compileCodePlaceholders({
1292+
code: [header, "Today's report", 'EOF', 'echo $API_KEY'].join('\n'),
1293+
language: CodeLanguage.Shell,
1294+
environmentVariables: { API_KEY: 'a-value' },
1295+
})
1296+
expect(compiled.resolvedSecretNames).toEqual(['API_KEY'])
1297+
}
1298+
const inBody = await compileCodePlaceholders({
1299+
code: ['cat <<EOF', "it's $API_KEY", 'EOF'].join('\n'),
1300+
language: CodeLanguage.Shell,
1301+
environmentVariables: { API_KEY: 'a-value' },
1302+
})
1303+
expect(inBody.resolvedSecretNames).toEqual(['API_KEY'])
1304+
})
1305+
12691306
it('ignores a shell variable that is not a configured secret', async () => {
12701307
const compiled = await compileCodePlaceholders({
12711308
code: 'echo "$PATH $HOME"',

‎apps/sim/lib/execution/code-placeholders/shell.ts‎

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -439,12 +439,21 @@ function getUnsupportedShellPosition(
439439
return undefined
440440
}
441441

442+
function heredocBodyRanges(heredocs: HeredocDeclaration[]): Array<[number, number]> {
443+
return heredocs.map((heredoc) => [heredoc.bodyStart, heredoc.removalEnd])
444+
}
445+
446+
/**
447+
* Jumps over `skippedRanges` (sorted heredoc bodies, which bash reads as data) so body prose
448+
* cannot shift quote context; bodies that need contexts are scanned on their own with `literalRoot`.
449+
*/
442450
function collectShellOccurrenceContexts(
443451
code: string,
444452
occurrences: CodePlaceholderOccurrence[],
445453
start: number,
446454
end: number,
447-
literalRoot: boolean
455+
literalRoot: boolean,
456+
skippedRanges: Array<[number, number]> = []
448457
): Map<CodePlaceholderOccurrence, ShellOccurrenceContext> {
449458
const occurrenceByStart = new Map(
450459
occurrences
@@ -455,11 +464,24 @@ function collectShellOccurrenceContexts(
455464
const frames: ShellScanFrame[] = [
456465
{ kind: 'root', quote: 'none', parenthesisDepth: 0, literalRoot },
457466
]
467+
let skippedRangeIndex = 0
458468

459469
for (let index = start; index < end; ) {
460470
const frame = frames.at(-1)
461471
if (!frame) break
462472

473+
while (
474+
skippedRangeIndex < skippedRanges.length &&
475+
skippedRanges[skippedRangeIndex][1] <= index
476+
) {
477+
skippedRangeIndex += 1
478+
}
479+
const skippedRange = skippedRanges.at(skippedRangeIndex)
480+
if (skippedRange && index >= skippedRange[0]) {
481+
index = skippedRange[1]
482+
continue
483+
}
484+
463485
const occurrence = occurrenceByStart.get(index)
464486
if (occurrence) {
465487
contexts.set(occurrence, { quote: frame.quote })
@@ -635,36 +657,46 @@ function recordShellDirectEnvironmentReads(
635657
}
636658
if (matches.length === 0) return
637659

638-
/**
639-
* A heredoc with a quoted delimiter (`<<'EOF'`) is literal, so nothing in its body expands.
640-
* The frame scanner below models quoting within a line, not heredoc bodies, so those are
641-
* excluded up front — otherwise a `$NAME` printed verbatim would be reported as a read that
642-
* never happened, and a usage trail must not claim uses that did not occur.
643-
*/
644-
const literalHeredocBodies = collectHeredocs(code)
645-
.filter((heredoc) => heredoc.quoted)
646-
.map((heredoc): [number, number] => [heredoc.bodyStart, heredoc.bodyEnd])
647-
648-
const candidates: CodePlaceholderOccurrence[] = []
649-
for (const candidate of matches) {
650-
if (isOffsetInRanges(candidate.index, literalHeredocBodies)) continue
651-
candidates.push({
660+
const candidates = matches.map(
661+
(candidate): CodePlaceholderOccurrence => ({
652662
start: candidate.index,
653663
end: candidate.index + candidate[0].length,
654664
raw: candidate[0],
655665
name: (candidate[1] ?? candidate[2]) as string,
656666
})
657-
}
658-
if (candidates.length === 0) return
667+
)
659668

660-
const contexts = collectShellOccurrenceContexts(code, candidates, 0, code.length, false)
669+
const heredocs = collectHeredocs(code)
670+
const contexts = collectShellOccurrenceContexts(
671+
code,
672+
candidates,
673+
0,
674+
code.length,
675+
false,
676+
heredocBodyRanges(heredocs)
677+
)
678+
/**
679+
* A heredoc with a quoted delimiter (`<<'EOF'`) is literal, so nothing in its body expands
680+
* and its candidates stay contextless — a usage trail must not claim uses that did not occur.
681+
*/
682+
for (const heredoc of heredocs) {
683+
if (heredoc.quoted) continue
684+
const bodyContexts = collectShellOccurrenceContexts(
685+
code,
686+
candidates,
687+
heredoc.bodyStart,
688+
heredoc.bodyEnd,
689+
true
690+
)
691+
for (const [candidate, shellContext] of bodyContexts) contexts.set(candidate, shellContext)
692+
}
661693
for (const candidate of candidates) {
662694
const shellContext = contexts.get(candidate)
663695
/**
664696
* No context means the scanner never reached this offset — it skipped the region as a
665-
* comment. Absence is therefore evidence the expansion does not run, not permission to
666-
* record it, so this reads as an allowlist rather than a denylist. Single quotes suppress
667-
* expansion outright.
697+
* comment or a quoted heredoc body. Absence is therefore evidence the expansion does not
698+
* run, not permission to record it, so this reads as an allowlist rather than a denylist.
699+
* Single quotes suppress expansion outright.
668700
*/
669701
if (!shellContext || shellContext.quote === 'single') continue
670702
context.recordDirectEnvironmentRead(candidate.name, candidate.start)
@@ -828,7 +860,8 @@ export async function compileShellPlaceholders(
828860
rootOccurrences,
829861
0,
830862
input.code.length,
831-
false
863+
false,
864+
heredocBodyRanges(heredocs)
832865
)
833866
for (const occurrence of rootOccurrences) {
834867
const occurrenceContext = rootContexts.get(occurrence)

0 commit comments

Comments
 (0)