Swapped Arguments, Burned Budget: Validate at the Very Top
set -euo pipefail will not save a script from swapped arguments. Whitelist guards and custom exit codes at the entry point stop the waste.
TL;DR
The script ran to completion on swapped arguments because set -euo pipefail only catches unset variables, not wrong values. The fix: eight guard lines at the top that validate the recipe path, integer, and budget using simple case globs with custom exit codes 45 and 46. Validate at the trust boundary, quote variables, keep it lightweight.
I launched the script and everything looked fine on screen: no red errors, no crash. Only when I opened the log afterwards did I see the whole job had run to completion on swapped command-line arguments, burning $0.137 of budget for nothing. The script kept executing because positional parameters are simply assigned from the script's arguments at invocation [2], and nothing downstream cared that the values made no sense.
My first guess was naive. I thought, "the second line already says set -euo pipefail, so the script should stop the moment something is off." That guess was wrong, and the gap it left is worth understanding.
The -u option in bash is genuinely useful: it makes the shell exit when a variable is unset or undefined [1]. But it does not care one bit if the variable is set with the wrong type, is empty, or got swapped with its neighbor. Those are perfectly valid assignments as far as the shell is concerned. Input validation belongs at the trust boundary, right at the top of the script, before any real work starts.
Closing the door before anything runs
The fix in my commit is eight guard lines at the very top of the launcher script. Community best practice points the same way: a script's own positional parameters should be checked at the top [4]. This is not cosmetic checking; it is the gatekeeper that ensures the script only runs when its inputs are actually valid.
First, the recipe path is made absolute, then tested for existence.
case "$RECIPE" in
/*) ;; # absolute path: use as-is
*) RECIPE="$PWD/$RECIPE" ;; # make absolute
esac
[ -f "$RECIPE" ] || { echo "LAUNCH-REFUSED: recipe not found: $RECIPE" >&2; exit 45; }If the recipe file is missing, the script stops immediately with exit code 45. That number is not arbitrary. Bash already reserves a set of statuses: 126 for a file that exists but is not executable, 127 for command not found, and 128+N for termination by a signal [6]. Choosing 45, safely outside the reserved range, lets the calling script tell this failure apart from every other kind.
The whitelist-by-negation trick
The part that usually confuses people is validating numbers. Instead of matching a correct numeric format, I use a whitelist-by-negation approach with case.
case "$TURNS" in (*[!0-9]*|'') echo "LAUNCH-REFUSED: max-turns must be an integer, got '$TURNS'" >&2; exit 46;; esacThe pattern *[!0-9]* works by inverting the logic. It looks for the presence of any character that is NOT a digit. Together with |'', which catches the empty-string case, it says "this value must consist only of digits" in one clause. Pass "10a" or nothing at all, and the pattern catches it and the script stops with exit code 46.
The same treatment applies to the budget argument, which must be a plain decimal.
case "$BUDGET" in (*[!0-9.]*|'') echo "LAUNCH-REFUSED: budget must be a plain decimal, got '$BUDGET'" >&2; exit 46;; esacWorth remembering: bash's case executes the command list of the FIRST matching pattern, scanned from top to bottom [5]. That makes the guard fast and deterministic. No heavy regex, just glob patterns built into the shell. One more thing: validation and quoting complement each other, they are not alternatives. Unquoted expansions can break under word splitting and glob expansion [3], so the toughest guard is wasted if the variables are quoted carelessly.
Why this approach makes sense
Some might ask, why not reach for a fancier validation tool? The answer is simplicity and speed. This launcher is an entry point. It has to stay lightweight and fast, without pulling in Python or Node.js just to check whether a string is a number.
Placing validation at the very top cuts off pointless work: the machine never allocates memory or spawns child processes for input that was wrong from the start. The caller can also catch exit code 45 or 46 and turn it into a friendlier message. An orchestration script, for example, can send a Slack notification that the recipe file is missing instead of letting the system die mid-run with a confusing stack trace.
I originally leaned on letting bash handle errors naturally through set -e. After watching swapped arguments slip past that radar, I changed my approach. Fail-fast validation at the door ensures the script only runs when conditions are genuinely safe, and that is the only sensible way to protect a system from unexpected input.