Conversation
|
@claude is attempting to deploy a commit to the Vercel Labs Team on Vercel. A member of the Team first needs to authorize it. |
🤖 auto-maintain reviewAutomated, advisory triage for
Review panel: 🔴 high highest severity just-bash maintainer code review: 🔴 high
General code review: 🔴 high
Adversarial security: 🔴 high
Adversarial security (second opinion): 🟡 medium
Standard Bash and host portability: 🟡 medium
Posted by auto-maintain. This automated code review is advisory; a human maintainer makes the call. |
… state
A `$(...)` / backtick substitution inside `$(( ))`, `(( ))` or an array
subscript was executed via `ctx.execFn`, which starts a fresh top-level
execution seeded from the instance's base state. It could not see any
variable the running script had assigned -- not even exported ones --
so the substitution silently produced a wrong number instead of an error:
X=abc
echo $(( $(echo "$X" | wc -c) )) # 1, GNU bash: 4
Route these through the same subshell-like execution that ordinary
command substitution uses, extracted as `runCommandSubstitution`.
Also replace `Number.parseInt(output, 10) || 0` with bash's behaviour of
splicing the output into the expression and parsing it as arithmetic, so
`$(( $(echo "1 + 2") ))` is 3 and unparsable output raises a syntax error
instead of evaluating to 0.
Keep substitution text verbatim through `preprocessArithInput` and
`expandDollarVarsInArithText`: now that the text is parsed as a shell
script, stripping its quotes word-split the arguments, and splicing
variable values into backticks let variable data run as shell syntax.
Reported downstream as ai-ecoverse/slicc#2978.
Signed-off-by: Lars Trieloff <lars@trieloff.net>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
585e040 to
6722def
Compare
Addresses review findings on the arithmetic command substitution fix. Subshell isolation: runCommandSubstitution rolled back only env, arrays, cwd and BASHPID, so a `set -u`, `shopt -s`, function definition, variable attribute or `previousDir` set inside $() leaked into the parent. Routing arithmetic substitutions onto this path made that reachable from `$(( ))` where the old detached execution had been isolated. Use the existing beginIsolatedShellState transaction, which snapshots every mutable namespace. This also fixes the same leak on plain $(), where it predates this PR. Data-to-code: bash re-parses a variable's value (and a substitution's output) as arithmetic but does not run expansions on it, so `$(...)` reached that way is a syntax error. The previous text scan for `$(` only caught it literally in substitution output; one level of variable indirection walked straight through to command execution. Track provenance on the resolution context instead, so the guard holds through any depth of indirection and covers variable values and array element values as well. Array subscripts are the exception: bash does expand `a[$(cmd)]` reached through data, so subscript evaluation clears the flag. spec-tests bugs.test.sh and array-assign.test.sh both pin that behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
|
Thanks — both findings were real and are fixed in 3d04a6f. One correction on the second one, where the suggested rule turned out to be too strict. 1. Incomplete subshell isolation — fixedConfirmed, and worse than the summary suggested: functions, echo $(( $(f() { :; }; set -u; cd /tmp; echo 1) ))
type f # -> "f is a function" bash: not found
echo $UNSET_VAR # -> unbound variable bash: empty, status 0Now uses the existing Worth noting for the record: this leak is not new to this PR — the extracted block is verbatim from the old 2. Guard bypassable via variable indirection — fixed, with a caveatConfirmed: v='$(touch /tmp/pwn)'; echo $(( $(echo v) )) # ran touchThe text scan for The caveat: "reject v='$(echo 1)'; echo $(( v )) # syntax error, nothing runs
x='a[$(echo 42)]=1'; echo $(( x )) # 1 — bash RUNS the substitutionVerified on bash 3.2 and 5.3; For a defensive-sandbox posture that's arguably still one execution path too many, but it is bash's documented behaviour and the spec suite asserts it — happy to gate it behind an option if you'd prefer to diverge deliberately. Also fixedThe Coverage added9 comparison cases in a new |
|
A new push changed this PR and the review now contains a higher-severity finding. See the updated review comment above. |
The comparison-tests job re-records fixtures against Linux bash and fails if `git diff` is non-empty. Two fixtures recorded on macOS bash 3.2 held a stderr string that Linux bash 5.x renders differently -- it numbers the line differently and, depending on version, says "arithmetic syntax error" rather than "syntax error" -- so the guard tripped even though all 906 comparison tests passed on both platforms. compareOutputs only compares stdout and the exit code, so the recorded stderr is documentation rather than an assertion. Store the Linux text and mark both fixtures `locked`, which is the documented mechanism for platform-specific values, and list them in the README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fails open to a wrong number
A
$(...)/ backtick command substitution nested inside$(( ))or(( ))ran in a detached shell seeded from the initial environment. It could not see any variable the running script had set — not even exported ones. Because the missing value produced empty output rather than an error, the arithmetic silently evaluated to a plausible wrong number:The shape it was reported as, where
"$X"vanishing shifted+%sinto-d's argument slot:Root cause
arithmetic.ts:452(numeric path) andarithmetic.ts:962(string path).ArithCommandSubstkeeps the substitution as raw text and ran it viactx.execFn, the Bash-levelexec(Bash.ts:818→execInScope) — a fresh top-level execution seeded from the instance's base state, not the running interpreter's.Ordinary command substitution does the right thing and is the model followed here:
expansion.ts:755+executes the parsed body viactx.executeScript, sharing the current state. That is why$(echo $(echo "$X"))always worked, and why theletform worked (it expands as a word first).The backtick form appeared correct only by accident —
expandDollarVarsInArithTexttextually pre-expanded$varinside backticks before the detached shell ran, which is its own problem (see below).What changed
runCommandSubstitutionextracted fromexpansion.tsintoexpansion/command-substitution.ts— the depth guard, env/array/cwd save-restore,expansionStderrplumbing,ExitErrorhandling and output-length check, unchanged. BothArithCommandSubstsites and the array-subscript sites inexpandSubscriptForAssocArray(samectx.execFndefect, reachable via${arr[$(...)]}) now go through it.ctx.execFnno longer appears anywhere in the expansion path.Number.parseInt(output, 10) || 0removed — behaviour change, called out deliberately. bash splices the substitution's output into the expression as text and parses that as arithmetic.parseIntwas a second fail-open:$(( $(echo "1 + 2") ))silently became2, and$(( $(echo "hello world") ))silently became0. The output is now parsed as an arithmetic expression, matching bash:$(( $(echo "1 + 2") ))$(( $(echo " 7 ") ))v=9; $(( $(echo v) ))$(( $(echo "0x10") ))$(( $(echo "010") ))$(( $(echo "hello world") ))$(( $(echo "") ))Note the empty case: I checked this against bash 5.3 and 3.2 rather than assuming. bash does not error on an empty operand when it is the whole expression —
$(( $(echo "") )),$(( $(true) ))and$(( $(echo abc) ))are all0, because the spliced text is empty / an unset variable name. The operand-expected error only fires in operand position ($(( 1 + $(echo "") ))), which falls out of the normal parser. So the new failure mode is a genuine syntax error on unparsable output, not a blanket error on emptiness.bash also does not re-expand the spliced text, so a
$(...)in the output is rejected rather than executed — this also stopsf() { echo '$(f)'; }; echo $(( $(f) ))from recursing forever.Substitution text is kept verbatim through
preprocessArithInputandexpandDollarVarsInArithText. Both used to rewrite the inside of the substitution, which was harmless when the text went to a detached shell but is not once it is parsed as a real shell script:preprocessArithInputstripped double quotes everywhere, turning$(printf %s "$Q")into$(printf %s $Q)and word-splitting the value.Q="a b"gave 2 instead of 4.expandDollarVarsInArithTextspliced variable values into backtick commands. WithQ='$(echo PWNED)',$(( \printf %s "$Q" | wc -c` ))returned 5 — the length ofPWNED— because the variable's data was re-parsed and executed as shell syntax. It now returns 13, the length of the literal, matching bash.$((` still falls through to be preprocessed as before.Known limitation, unchanged
Splicing is done at the value level, not the text level, so precedence across the substitution boundary still differs:
$(( $(echo "2 + 3") * 4 ))is 20 here and 14 in bash. That is the same limitation already documented at the top ofarithmetic.tsfor$varexpansion; making it exact needs the substitution expanded before the arithmetic is parsed, which is a broader change than this fix.Tests
src/comparison-tests/arithmetic-command-substitution.comparison.test.ts— 27 cases recorded against real bash: variable visibility (plain,${:-}, exported, function,local, cwd), all five entry points ($(( )),(( )), backticks,let,a=$(( )), array subscript), quoting preservation, nesting, output splicing, empty/failing substitutions, and subshell isolation. Verified identical on bash 3.2 and 5.3 before recording.src/interpreter/arithmetic-command-substitution.test.ts— 10 unit tests asserting full stdout and stderr for what fixtures don't cover: stderr forwarding at expansion time, the syntax-error paths, the nesting guard, and that aBash({ env: { X: ... } })initial value no longer shadows script state.Validation
pnpm typecheck && pnpm lint && pnpm knipclean.pnpm test:run: 15647 passed, 98 skipped. The 6 remaining failures are pre-existing CPython-WASM/worker tests (python3lazy-load, sys.path, traceback, worker-protocol desync) that fail identically on the base commit on this machine — confirmed by re-running them against unmodifiedsrc/interpreter.Scope is this bug only;
expansion/array-prefix-suffix.tsandcontrol-flow.tsare untouched.Credit to the downstream report ai-ecoverse/slicc#2978 for the reproduction.
Review follow-up (3d04a6f)
runCommandSubstitutionnow uses the existingbeginIsolatedShellStatetransaction instead of hand-rolling a partial rollback, so functions,set -u,shopt, variable attributes andpreviousDirset inside$()are discarded like bash discards a subshell's. The partial rollback was inherited verbatim from the oldexpansion.tsand leaked identically on plain$()onmain; routing arithmetic substitutions onto that path is what made it reachable from$(( )), so this closes the pre-existing leak too.$(in substitution output was bypassable with one level of variable indirection (v='$(cmd)'; $(( $(echo v) ))executedcmd). Replaced with provenance tracking on the resolution context: anything parsed out of data — a variable's value, an array element's value, a substitution's output — evaluates with command substitution disabled, at any indirection depth. Array subscripts are the deliberate exception, because bash does expanda[$(cmd)]reached through data;spec-testsbugs.test.shandarray-assign.test.shboth pin that, and treating it as an error broke them.Isolation and provenance coverage lives in
arithmetic-command-substitution-isolation.comparison.test.ts(9 cases recorded against real bash) plus 3 unit tests. Both commits are GPG-signed and verified.🤖 Generated with Claude Code