On 1 October 2026 I purged Cloudflare's cache with a list of URLs, and the only note that came out was purged 1 URLs. I had built the list as one string and passed it along through an unquoted variable. What reached Cloudflare was not a list but a single "file" holding the entire string.
That "file" is not a typo. Cloudflare's purge API takes the addresses to drop in a files field, one URL per element, so one element still counts as one no matter how many addresses are tucked inside it. The cause I wrote down that day was short: zsh does not word-split an unquoted $VAR.
That sentence was nothing new in my notes. On one site project the same cause had already been recorded five times, the latest on 23 September. Three days later it surfaced again in a security sweep of my own machine, this time as a false zero. The purge was just one of its shapes, and not the last one either.
In zsh, an unquoted $VAR stays one word
The habit comes from bash. In bash, an unquoted variable is split into several words on spaces, tabs, and newlines, and each piece becomes an argument of its own. Plenty of scripts lean on that deliberately. zsh, the default shell on macOS, does not do it. In zsh an unquoted variable stays one word, whatever it holds.
The minimal example below uses neutral names and is not taken from any project script:
v="one two three"
set -- $v
echo $#In bash the last line prints 3, and $1 through $3 hold the three words. In zsh it prints 1. There $1 holds the whole string, $2 and $3 are empty, and nothing errors, because as far as zsh is concerned nothing went wrong. Bash-style splitting in zsh is an option called SH_WORD_SPLIT, off by default, and there is also the ${=v} form to force one expansion to split. Both make a script depend on zsh, and the second one fails outright with bad substitution as soon as bash runs the script.
This is also not a case of syntax the other shell simply does not know, like a PowerShell here-string that CMD reads literally. set -- $v is valid in both bash and zsh, and both run it without complaint. The only difference is how many words come out.
Five times on the same project
The place it hit most often was the recording rig I use to audit a site. Its main script, audit-rekam.sh, records eight screen sizes, builds contact sheets, and pulls frames at 2 fps. During the re-audit on the night of 11 September 2026, my notes recorded that set -- $v in zsh did not split words and every screen was recorded at 1440x900. Eight sizes that were supposed to differ all came out the same.
The same 1440x900 shows up in the recorder's own code. rekam.cjs falls back to a width of 1440 and a height of 900 when --w and --h are missing, zero, or not numbers:
const w = +args.w || 1440
const h = +args.h || 900The + turns the argument into a number. A missing argument or non-numeric text becomes NaN, an empty string becomes 0, and || treats both as falsy, so anything that is not a number quietly becomes 1440x900. My notes did not keep the shape of the loop used back then, so I cannot show the exact arguments. What I can show is a pair of facts that fit together. zsh puts the whole string into one argument and leaves the rest empty, while the recorder turns a width and height that never arrived as numbers into exactly the size that showed up in every recording. No layer complained, and the comment at the top of the script now records the result:
# Bash, bukan zsh: zsh tidak memecah kata pada `set -- $v`, dan itu sudah
# tiga kali membuat label dan ukuran salah tanpa satu pun galat.In English: bash, not zsh, because zsh does not split words on set -- $v, and that has already made the labels and sizes wrong three times without a single error.
The rule I wrote alongside the 1440x900 incident was simply bash, not zsh. The same day, in the middle of verifying the site's patch, zsh's set -- $v was recorded claiming its fourth victim, and I tightened the rule: screen-size loops may only run through a bash script in a file.
The fifth came on 23 September, from a direction that rule did not name. For that day's audit I made two APFS clones of the site, then built both and served them with astro preview on ports 4411 and 4412. The command that started those two preview servers used set -- $pair. That was not a screen-size loop, so the 11 September rule did not reach it, even though the cause was exactly the same.
What the script says now
The rig's fix lives in the code, not just in the notes. audit-rekam.sh opens with the shebang #!/usr/bin/env bash, and the usage line in its comments calls bash explicitly. The eight screen sizes are no longer looped over a variable either. Each one is written as a single call with explicit arguments:
rekam desktop-besar 1920 1080 1 wheel 800
rekam desktop 1440 900 1 wheel 700
rekam laptop-pendek 1280 720 1 wheel 700
rekam tablet-lebar 1024 768 2 touch 900
rekam tablet 768 1024 2 touch 900
rekam ponsel-besar 430 932 2 touch 900
rekam ponsel 390 844 2 touch 900
rekam ponsel-kecil 360 780 2 touch 900The rekam function takes the label, width, height, dpr, mode, and speed from six positional arguments. On lines like these, the arguments are separated by the command grammar before any expansion happens, so bash and zsh read them the same way. Eight lines are longer than one loop, but not one of them depends on word splitting.
As for status, I will say it plainly. This form is in the script, but my notes contain no dedicated test of the shell fix itself. The 11 September verification tested the site's patch, not the rig, and the fourth victim was recorded in the middle of that very verification.
A false zero in a security sweep, and a rejected AWS profile
On 26 September 2026 I ran a repeat security sweep over git hooks and npm installs on this machine, using nothing but find and grep. It covered 47 repos in my home folder, with 43 hooks folders holding 602 files, every one of them a .sample. In the middle of that sweep, find $R with R holding several paths was not split by zsh, and the result was a false zero. find receives the entire contents of R as one path that does not exist, so that call never looks at a single file.
This is the most dangerous shape of the same trap. On the recording rig, a wrong result can still be caught from the recordings. In a security sweep, zero is the answer you hope for, and a false zero reads exactly like a clean machine. My notes from that day call it a trap that recurred yet again, with the rule that multi-path sweeps must be bash scripts with arrays. The same notes hold the sweep's numbers, but they do not say what was done once the false zero came to light.
My AWS access notes hold yet another shape, and this one came after the purge, on 6 October. In zsh, an unquoted $P holding --profile x --region y is read as one word, and the CLI rejects it. What I wrote down for zsh is to pass the profile through environment variables in front of the aws command. The values below have been swapped for neutral names:
AWS_PROFILE=profile-name AWS_REGION=region-name AWS_PAGER="" aws ...Assignments in front of a command are separated by the shell's grammar, not by variable expansion, so nothing needs splitting, and the same form behaves identically in bash.
The cure: stop depending on word splitting
For the 1 October purge, the cure I recorded is to build an array and pass it as "${arr[@]}". In a minimal example with neutral names, the difference shows in the argument count:
urls=(https://example.test/a https://example.test/b https://example.test/c)
set -- "${urls[@]}"
echo $#This time bash and zsh both print 3. "${arr[@]}" expands to one word per element in both shells, and an element that happens to contain a space stays intact. There is no option to switch on and no shell habit to remember. The cure also went straight onto the real purge. A few seconds after purged 1 URLs came back, I sent the same list again as an array of 16 elements, and the note changed to purged 16 URLs, 0 failed. Two URLs I checked afterward answered with cf-cache-status: MISS, which means both were fetched straight from the origin server rather than from the cache.
For longer loops and sweeps, the rules point to the same place, a bash script in a file with a shebang that names bash. A script like that runs under bash even when the terminal is zsh, as long as it is invoked as ./script.sh or bash script.sh. Pasting its contents line by line into a zsh prompt, calling it with source, or running it with zsh script.sh throws that guarantee away.
Rules that trail by one shape
Lined up, every rule I wrote through the end of September turns out to name the shape that had just bitten. The 1440x900 recordings produced a rule for the recording rig, the fourth victim tightened it into a rule for screen-size loops, and the false zero in the security sweep produced a rule for multi-path sweeps. The next incident always arrived from a direction no rule had named yet: two preview servers on 23 September, find on 26 September, then a list of URLs for a purge on 1 October.
What I wrote down after that purge actually went wider than the rules before it. It recorded the cause for any unquoted $VAR, not just for the purge, together with the array cure. On 6 October a $P holding AWS profile options still went out unquoted, and the cure I recorded afterward was once again specific to that shape, passing the profile through the environment.
So what I take from that sequence is not one more rule for the next shape. I do not treat the wider boundary as a guarantee either, because its general form was already written down five days before $P got through. I still hold to what it says: once a command needs several arguments out of one variable, do not leave the splitting to the shell. Put the values in an array and pass "${arr[@]}", or move the command into a bash script in a file. Both behave the same in bash and in zsh, but both only help when they are chosen while the command is being written, and that is the step that was skipped on 6 October.
The purged 1 URLs note was honest from the start. The shell sent one word, Cloudflare received one entry, and the number one described exactly what happened. The only thing that was off was my assumption about how many words a variable carries.