Review & Verify Updated 2026-09 9 min read View as Markdown

The shell mistakes language models actually make

Generated shell is usually correct on the happy path and dangerous on every other one. Almost all of it is caught by one tool.

Shell has the widest gap of any language on this network between "works when I ran it" and "correct". Generated shell is written in the style of the tutorials it learned from, and tutorial shell omits every guard, because guards make examples longer.

The good news: shellcheck catches the large majority of what follows. If you take one thing from this page, install it.

Quoting and expansion#

1. Unquoted variables#

shell
cp $src $dst              # breaks on any path with a space
rm -rf $BUILD_DIR         # breaks catastrophically on an unset variable

Word splitting and glob expansion happen on unquoted expansions. A filename with a space becomes two arguments; a filename with * becomes a glob.

Correct: cp -- "$src" "$dst". Always quote. There are almost no exceptions.

Catch it with: shellcheck SC2086 — its most-triggered rule.

2. Parsing ls#

shell
for f in $(ls *.txt); do ...

Breaks on spaces, newlines and unusual characters, and produces a literal *.txt when nothing matches.

Correct:

shell
for f in ./*.txt; do
  [[ -e "$f" ]] || continue     # the glob may not have matched
  ...
done

3. Unquoted array expansion#

shell
"${arr[@]}"        # correct: one word per element
"${arr[*]}"        # one word, joined by the first IFS character
${arr[@]}          # subject to splitting. wrong.

Generated array code gets this wrong regularly, and it is silent until an element contains a space.

Error handling#

4. No set -e, or set -e alone#

shell
#!/bin/bash
cd /some/path          # if this fails...
rm -rf ./*             # ...this runs in the wrong directory

Correct: the four-line header.

shell
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR

set -u in particular prevents the rm -rf "$UNSET_VAR/" class of accident, which is the worst outcome available in this language.

5. Exit codes lost through a pipe#

shell
generate | grep -q pattern    # $? is grep's, not generate's

set -o pipefail fixes it. ${PIPESTATUS[@]} if you need the individual codes.

6. cd without a check#

shell
cd "$dir"                     # if it fails, the rest runs where you started
rm -rf ./build

Correct: cd "$dir" || exit 1, and verify a marker file exists before anything destructive.

Subshells and scope#

7. Variables set in a pipeline are lost#

shell
count=0
find . -name "*.log" | while read -r f; do
  count=$((count + 1))        # a subshell. the outer count stays 0.
done
echo "$count"                 # 0

The right-hand side of a pipe runs in a subshell. Correct: process substitution.

shell
while read -r f; do
  count=$((count + 1))
done < <(find . -name "*.log")

8. read without -r#

shell
while read line; do          # backslashes get mangled

read -r always. And IFS= read -r line to preserve leading and trailing whitespace.

Tests and comparisons#

9. [ versus [[#

shell
[ $x = "y" ]                  # breaks if x is empty or contains spaces
[[ $x == "y" ]]               # safe: no word splitting inside [[ ]]

In bash, use [[ ]]. Use [ ] only when POSIX portability is a real requirement.

10. String versus numeric comparison#

shell
[[ "$a" > "$b" ]]             # string comparison. "9" > "10" is true.
(( a > b ))                   # numeric

11. Testing command success by parsing output#

shell
if [[ $(command) == *"success"* ]]; then     # fragile
if command; then                             # use the exit code

Destructive operations#

12. rm -rf on anything computed#

The single highest-risk pattern in generated shell.

shell
rm -rf "$dir/$subdir"         # what if either is empty?

Defences, in order of value: set -u; validate the variable is non-empty and not /; use -- before paths; prefer trash for interactive use; add a --dry-run flag that prints instead.

shell
[[ -n "${dir:-}" && "$dir" != "/" ]] || { echo "refusing" >&2; exit 1; }

13. eval#

Almost never necessary and almost always an injection. If generated code contains eval on anything interpolated, rewrite it — usually with an array.

Portability#

14. GNU flags on macOS#

sed -i takes an argument on BSD sed and not on GNU sed. date -d is GNU-only. Generated scripts assume GNU because most Linux examples do. If your team is mixed, either mandate gsed/gdate or avoid the flags.

15. #!/bin/bash versus #!/usr/bin/env bash#

macOS ships bash 3.2 at /bin/bash, which lacks associative arrays and ${var,,}. Use #!/usr/bin/env bash so a modern bash from Homebrew is found.

The one tool#

shell
shellcheck -S warning scripts/*.sh
shfmt -w -i 2 -ci -bn scripts/

shellcheck catches items 1, 2, 3, 5, 7, 8, 9, 10 and flags several others. It has almost no false positives and every warning links to an explanation.

Put it in your agent's post-edit hook and generated shell arrives already fixed — see shell scripts as agent guardrails.

The short version

Install shellcheck. Use the four-line header. Quote everything. Then the only thing left to read for is whether the script does the right thing — and if it is over 100 lines, the answer is probably to rewrite it in Python.

Common questions#

Is shellcheck really enough?#

For the syntax-level bugs, close to it — quoting, splitting, subshells, comparison operators. What it cannot tell you is whether a destructive command is pointed at the right path, which is the failure that actually hurts. That one needs set -u, validation, and a dry-run flag.

Why does generated shell omit error handling?#

Because the shell in the training data omits it. Tutorials, Stack Overflow answers and README snippets are all written for brevity, and none of them are expected to run unattended. The model reproduces the distribution.

When should I stop writing shell?#

At about 100 lines, or the first time you need an array of anything other than strings, real error handling, or a function that returns a value. Beyond that, Python or Go is shorter, testable and far easier to review.

Get the Bash agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Bash. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.

Disclosure: some links on this page are affiliate links. If you buy something through one, we earn a commission at no extra cost to you. We only list tools we would tell a friend to use, and we say so when we have not used something ourselves. This is how the site stays free and ad-light.