Bash Hardening Guide
Concise correctness pointers for non-trivial bash. Surface the traps LLMs reliably miss when emitting "robust" scripts.
Assumes you can already write if/while/for, run chmod +x, and read a stack trace. This skill covers the correctness layer — the parts models gloss over: set -e exceptions, IFS, quoting, parameter expansion, trap inheritance, array gotchas, version differences.
When to use
Load when the task is:
- Writing/auditing installers, CI scripts, ops tooling, release scripts
- Diagnosing a script that "should work" but silently does the wrong thing
- Adding
set -euo pipefail or strict-mode hardening
- Trap / cleanup / signal handling
- Array manipulation with empty-array / strict-mode interactions
- Cross-platform bash (macOS 3.2 vs Linux 4+/5+)
- ShellCheck triage (SC2086, SC2046, SC2155, SC2015, SC2128, SC2178, SC2207)
Do NOT load for: trivial one-liners, interactive shell use, "what is $PATH", POSIX-sh-only constraints (dash/ash), zsh/fish — those don't need this skill.
set -euo pipefail — what each option misses
set -e (errexit) does not trigger when the failing command is:
- Inside
&& / || / ! chains. cmd1 && cmd2 — if cmd1 fails, script continues.
- The condition of
if, while, until. if cmd; then ... masks cmd's failure.
- Inside a function called from any of the above contexts. Once disabled at the call site, errexit stops applying inside the function body.
- Inside command substitution:
foo=$(cmd_that_fails) — exit status discarded. Use set -o errexit; foo=$(cmd) || exit or check $? immediately.
- Inside process substitution:
cat <(cmd_that_fails) — exit silently discarded.
set -E (errtrace) propagates the ERR trap into functions, command substitutions, subshells. Pair set -eE if you have an ERR trap — without -E, the trap fires only at top level.
set -u (nounset) edge cases:
${var:-default} and ${var-default} are safe under -u.
"${arr[@]}" of an empty array errors under -u in bash 4.3 and earlier (and was buggy through 5.0). Defensive form: "${arr[@]:+${arr[@]}}" or "${arr[@]+${arr[@]}}".
- Always initialise arrays explicitly:
arr=() — never assume declare -a arr is enough under -u.
set -o pipefail:
- Without it:
false | true exits 0 (only last command's status counts).
- With it: pipeline exits with the rightmost non-zero status.
- For per-segment exit codes use
${PIPESTATUS[@]} immediately after the pipeline (resets on next command).
(( expr )) and let return non-zero when the result is zero. Under set -e, (( count = 0 )) aborts the script. Defensive form: (( count = 0 )) || true or count=0 plain assignment.
local var=$(cmd) masks cmd's exit code — local itself returns 0. Same for declare, export, readonly. Split: local var; var=$(cmd).
Recommended top-of-script: set -Eeuo pipefail; IFS=$'\n\t'.
IFS and word splitting
- Default IFS is
$' \t\n' (space, tab, newline). Unquoted expansions are split on any of these, then glob-expanded.
IFS=$'\n\t' (Bash strict-mode idiom) defangs space-splitting — filenames with spaces survive for f in $files. Cost: any code that genuinely splits on spaces breaks.
- IFS is global — set inside a function, it leaks. Use
local IFS=... inside functions.
"${arr[*]}" joins elements with the first character of IFS (so with default IFS, a space). "${arr[@]}" keeps elements separate. Different operations, never interchangeable.
"$@" (quoted) preserves each positional arg as one word; $@ unquoted word-splits — same trap as $*.
read honours IFS. while IFS= read -r line strips nothing — quoted-empty IFS preserves leading/trailing whitespace; -r disables backslash interpretation.
Quoting reflexes
- Always quote variable expansions:
"$var", "${arr[@]}", "$(cmd)", "${var/old/new}". Unquoted = word-split + glob.
[[ ]] does not word-split RHS — [[ $foo = bar ]] works without quotes around $foo. [ ] does word-split — quote or you'll get "too many arguments" on values with spaces or empty values.
- Inside
[[ $a = $b ]], $b is treated as a glob pattern. Quote to compare literally: [[ $a = "$b" ]]. Inside =~, quoting the RHS makes it literal — for regex, leave RHS unquoted (or store the pattern in a variable and reference unquoted).
- Backticks
`cmd` — use $(cmd) instead. Nestable, no backslash-escape weirdness.
- Tilde expansion does not happen inside double quotes:
"~/file" is literal. Use "$HOME/file".
- Heredoc:
<<EOF expands $var and $(cmd). <<'EOF' (quoted delimiter) treats body literally. <<-EOF strips leading tabs only (not spaces) — useful for indentation in scripts.
[[ ]] vs [ ] vs (( ))
[[ ]] — bash builtin. No word splitting on RHS, regex =~, glob match in ==/!= (don't quote pattern), short-circuit && / ||. Bash/ksh/zsh only — not POSIX.
[ ] — equivalent to /usr/bin/test. Subject to word splitting; must quote vars. POSIX. Avoid -a/-o (deprecated, ambiguous) — chain with &&/||: [ -e "$f" ] && [ -r "$f" ].
(( )) — arithmetic. Integer math, C-style (++, **, %, ternary). Variables auto-dereferenced — (( x = y + 1 )) not (( x = $y + 1 )). Returns non-zero when result is 0 — see set -e interaction above.
Parameter expansion (replace sed/awk for simple ops)
${var:-default} — default if unset or empty.
${var-default} — default if unset only (empty stays empty).
${var:=default} — assign default if unset/empty.
${var:?msg} — print msg to stderr and exit if unset/empty. Useful for required-arg checks.
${var:+alt} — alt if set/non-empty (inverse of :-).
${#var} — string length. ${#arr[@]} — array element count.
${var:offset:length} — substring. Negative offset needs space or parens: ${var: -3} or ${var:(-3)}.
${var#pat} — strip shortest prefix match. ${var##pat} — longest. (Mnemonic: # is at start of comments.)
${var%pat} — strip shortest suffix. ${var%%pat} — longest. (Mnemonic: % is at the end.)
${var/pat/repl} — replace first match. ${var//pat/repl} — replace all. ${var/#pat/repl} — match at start. ${var/%pat/repl} — match at end.
${var^^} / ${var,,} — upper/lower (bash 4+). ${var^} / ${var,} — first char only.
${!prefix*} / ${!prefix@} — names of variables matching prefix. ${!arr[@]} — array indices/keys.
Arrays
- Indexed:
arr=(a b "c d"). Access: ${arr[0]}, all elements "${arr[@]}", indices "${!arr[@]}", count ${#arr[@]}.
- Associative:
declare -A m; m[key]=val — bash 4.0+. macOS default bash is 3.2.57 (Apple stuck at last GPLv2 release) — brew install bash for 5+ or fall back to parallel indexed arrays.
- Append:
arr+=(d e). Single-element append: arr+=(x) not arr+=x (the latter concatenates to element 0).
"${arr[@]}" — each element a separate word. "${arr[*]}" — single string joined by IFS[0]. Different.
mapfile -t lines < file (or readarray) — read file into array, one line per element, -t trims newline. Bash 4+.
- A bare
arr=value assigns to index 0 of an existing array — silent footgun if you meant to overwrite.
Trap semantics
Process substitution and pipelines
<(cmd) — bash makes cmd's stdout available as a file path (/dev/fd/N or named FIFO). >(cmd) — same but for stdin. Not POSIX — bash/ksh/zsh only; don't use under #!/bin/sh.
- Exit status of the substituted command is not propagated.
cat <(false) — cat reports success.
- Use it to fix the subshell-loop trap:
cmd | while read x; do count=$((count+1)); done runs the loop in a subshell — count outside is unchanged. Rewrite as while read x; do ...; done < <(cmd) to keep the loop in the parent shell.
- Alternative:
shopt -s lastpipe (bash 4.2+, only when job control is off — i.e. non-interactive scripts) makes the last pipeline segment run in the parent.
Globbing options
- Default: a glob with no matches expands to the literal pattern —
for f in *.txt iterates once with f="*.txt" if no .txt files exist.
shopt -s nullglob — unmatched glob expands to nothing. Loop iterates zero times. Sane default for scripts.
shopt -s failglob — unmatched glob is a hard error.
shopt -s globstar — ** matches recursively. Bash 4+.
shopt -s extglob — ?(...), *(...), +(...), @(...), !(...) extended patterns.
shopt -s dotglob — globs match dotfiles.
Common dangerous patterns
cmd | while read; do ...; done — loop runs in subshell; mutations don't escape. Fix: while read; do ...; done < <(cmd) or shopt -s lastpipe.
eval "$user_input" — almost always wrong. Replace with arrays, declare -n namerefs (bash 4.3+), or restructure.
[ "$a" = "$b" -a "$c" = "$d" ] — -a/-o are deprecated and ambiguous. Use [ "$a" = "$b" ] && [ "$c" = "$d" ] or [[ $a = $b && $c = $d ]].
cd $dir; rm -rf * without quotes — word-splits and globs $dir. Quote: cd "$dir".
A && B || C is not if-then-else. If A succeeds and B fails, C runs anyway. Use a real if.
local var=$(cmd) — local masks cmd's exit. Split into two lines.
echo -n / echo -e — non-portable (BSD echo differs). Use printf '%s' "$var" / printf '%s\n' "$var".
find . -name '*.tmp' | xargs rm — breaks on filenames with spaces/newlines. Use find ... -print0 | xargs -0 rm or find ... -delete or find ... -exec rm {} +.
- Backticks. Use
$( ).
Locale and portability
LC_ALL=C sort (and grep, comm, uniq, awk) — deterministic byte-order, faster, avoids locale-dependent collation surprises (A vs a ordering, [a-z] not matching what you expect).
LC_NUMERIC=C printf '%f' 1.5 — decimal point stays .. Without it, locales like de_DE use , and printf errors.
#!/bin/bash vs #!/usr/bin/env bash — the latter follows $PATH, picks up brew install bash's 5.x on macOS instead of the 3.2.57 in /bin/bash.
- macOS-shipped bash is 3.2.57 — no associative arrays, no
mapfile, no ${var^^}, no ** globstar, no &>>. Either require bash 4+ in your shebang/preamble or write to 3.2 floor.
Tooling
- ShellCheck — run on every script. Top rules to internalise:
SC2086 — quote variable expansions to prevent globbing/word-splitting.
SC2046 — quote command substitution to prevent same.
SC2155 — declare-and-assign masks exit code (local x=$(cmd)).
SC2015 — A && B || C is not if-then-else.
SC2128 — using array name without index gets element 0 only.
SC2178 — assigning string to array variable.
SC2207 — use mapfile or array assignment instead of arr=( $(cmd) ).
SC2034 — variable defined but never used (often a typo).
- shfmt — formatter; pin a version in CI, run
shfmt -d for diff-only check.
- bats-core — bash test framework when scripts get non-trivial.
set -x — trace. Pair with PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: ' for richer output.
Authoritative references
Official Bash manual (gnu.org/software/bash/manual):
Greg's Wiki / BashFAQ (mywiki.wooledge.org):
ShellCheck wiki — every SCxxxx has its own page at shellcheck.net/wiki/SCxxxx (e.g. SC2155, SC2015).
Guardrails
Before recommending a non-trivial bash pattern (strict-mode preamble, trap, array idiom, parameter-expansion trick):
- Quote the specific option / builtin / expansion form by name.
- State the bash version floor if it matters (3.2 macOS / 4.0 / 4.3 / 4.4 / 5.0).
- Cite the BashFAQ / Bash manual / ShellCheck rule for non-obvious behaviour.
- Run
shellcheck on the produced script and address every finding — silence only with an inline # shellcheck disable=SCxxxx plus a one-line reason.
A bash script that passes ShellCheck is the floor, not the ceiling.
1---2name: bash-hardening3description: Deep bash correctness — `set -euo pipefail` traps, IFS/word-splitting, trap inheritance, process substitution, array gotchas, version-gated features, ShellCheck "looks right but isn't" patterns. Load when writing or hardening non-trivial bash scripts (installers, CI, unattended ops). Skip for basic if/while/for, one-shot interactive commands, or zsh/fish/POSIX-sh work. Triggers on: "set -e not triggering", "pipefail", "trap ERR", "PIPESTATUS", "macOS bash 3.2", "shellcheck SC2086", "harden bash script", "robust bash".4---56# Bash Hardening Guide78Concise correctness pointers for non-trivial bash. Surface the traps LLMs reliably miss when emitting "robust" scripts.910Assumes you can already write `if`/`while`/`for`, run `chmod +x`, and read a stack trace. This skill covers the **correctness layer** — the parts models gloss over: `set -e` exceptions, IFS, quoting, parameter expansion, trap inheritance, array gotchas, version differences.1112## When to use1314Load when the task is:15- Writing/auditing installers, CI scripts, ops tooling, release scripts16- Diagnosing a script that "should work" but silently does the wrong thing17- Adding `set -euo pipefail` or strict-mode hardening18- Trap / cleanup / signal handling19- Array manipulation with empty-array / strict-mode interactions20- Cross-platform bash (macOS 3.2 vs Linux 4+/5+)21- ShellCheck triage (SC2086, SC2046, SC2155, SC2015, SC2128, SC2178, SC2207)2223**Do NOT load** for: trivial one-liners, interactive shell use, "what is `$PATH`", POSIX-sh-only constraints (dash/ash), zsh/fish — those don't need this skill.2425## `set -euo pipefail` — what each option misses2627`set -e` (errexit) does **not** trigger when the failing command is:28- Inside `&&` / `||` / `!` chains. `cmd1 && cmd2` — if `cmd1` fails, script continues.29- The condition of `if`, `while`, `until`. `if cmd; then ...` masks `cmd`'s failure.30- Inside a function called from any of the above contexts. Once disabled at the call site, errexit stops applying inside the function body.31- Inside command substitution: `foo=$(cmd_that_fails)` — exit status discarded. Use `set -o errexit; foo=$(cmd) || exit` or check `$?` immediately.32- Inside process substitution: `cat <(cmd_that_fails)` — exit silently discarded.3334`set -E` (errtrace) propagates the `ERR` trap into functions, command substitutions, subshells. **Pair `set -eE` if you have an `ERR` trap** — without `-E`, the trap fires only at top level.3536`set -u` (nounset) edge cases:37- `${var:-default}` and `${var-default}` are safe under `-u`.38- `"${arr[@]}"` of an empty array errors under `-u` in bash 4.3 and earlier (and was buggy through 5.0). Defensive form: `"${arr[@]:+${arr[@]}}"` or `"${arr[@]+${arr[@]}}"`.39- Always initialise arrays explicitly: `arr=()` — never assume `declare -a arr` is enough under `-u`.4041`set -o pipefail`:42- Without it: `false | true` exits 0 (only last command's status counts).43- With it: pipeline exits with the rightmost non-zero status.44- For per-segment exit codes use `${PIPESTATUS[@]}` immediately after the pipeline (resets on next command).4546`(( expr ))` and `let` return **non-zero when the result is zero**. Under `set -e`, `(( count = 0 ))` aborts the script. Defensive form: `(( count = 0 )) || true` or `count=0` plain assignment.4748`local var=$(cmd)` masks `cmd`'s exit code — `local` itself returns 0. Same for `declare`, `export`, `readonly`. Split: `local var; var=$(cmd)`.4950Recommended top-of-script: `set -Eeuo pipefail; IFS=$'\n\t'`.5152## IFS and word splitting5354- Default IFS is `$' \t\n'` (space, tab, newline). Unquoted expansions are split on **any** of these, then glob-expanded.55- `IFS=$'\n\t'` (Bash strict-mode idiom) defangs space-splitting — filenames with spaces survive `for f in $files`. Cost: any code that genuinely splits on spaces breaks.56- IFS is **global** — set inside a function, it leaks. Use `local IFS=...` inside functions.57- `"${arr[*]}"` joins elements with the **first character of IFS** (so with default IFS, a space). `"${arr[@]}"` keeps elements separate. Different operations, never interchangeable.58- `"$@"` (quoted) preserves each positional arg as one word; `$@` unquoted word-splits — same trap as `$*`.59- `read` honours IFS. `while IFS= read -r line` strips nothing — quoted-empty IFS preserves leading/trailing whitespace; `-r` disables backslash interpretation.6061## Quoting reflexes6263- Always quote variable expansions: `"$var"`, `"${arr[@]}"`, `"$(cmd)"`, `"${var/old/new}"`. Unquoted = word-split + glob.64- `[[ ]]` does **not** word-split RHS — `[[ $foo = bar ]]` works without quotes around `$foo`. `[ ]` does word-split — quote or you'll get "too many arguments" on values with spaces or empty values.65- Inside `[[ $a = $b ]]`, `$b` is treated as a **glob pattern**. Quote to compare literally: `[[ $a = "$b" ]]`. Inside `=~`, quoting the RHS makes it literal — for regex, leave RHS unquoted (or store the pattern in a variable and reference unquoted).66- Backticks `` `cmd` `` — use `$(cmd)` instead. Nestable, no backslash-escape weirdness.67- Tilde expansion does **not** happen inside double quotes: `"~/file"` is literal. Use `"$HOME/file"`.68- Heredoc: `<<EOF` expands `$var` and `$(cmd)`. `<<'EOF'` (quoted delimiter) treats body literally. `<<-EOF` strips **leading tabs only** (not spaces) — useful for indentation in scripts.6970## `[[ ]]` vs `[ ]` vs `(( ))`7172- `[[ ]]` — bash builtin. No word splitting on RHS, regex `=~`, glob match in `==`/`!=` (don't quote pattern), short-circuit `&&` / `||`. Bash/ksh/zsh only — not POSIX.73- `[ ]` — equivalent to `/usr/bin/test`. Subject to word splitting; **must** quote vars. POSIX. Avoid `-a`/`-o` (deprecated, ambiguous) — chain with `&&`/`||`: `[ -e "$f" ] && [ -r "$f" ]`.74- `(( ))` — arithmetic. Integer math, C-style (`++`, `**`, `%`, ternary). Variables auto-dereferenced — `(( x = y + 1 ))` not `(( x = $y + 1 ))`. Returns non-zero when result is 0 — see `set -e` interaction above.7576## Parameter expansion (replace sed/awk for simple ops)7778- `${var:-default}` — default if unset **or empty**.79- `${var-default}` — default if **unset only** (empty stays empty).80- `${var:=default}` — assign default if unset/empty.81- `${var:?msg}` — print `msg` to stderr and exit if unset/empty. Useful for required-arg checks.82- `${var:+alt}` — `alt` if set/non-empty (inverse of `:-`).83- `${#var}` — string length. `${#arr[@]}` — array element count.84- `${var:offset:length}` — substring. Negative offset needs space or parens: `${var: -3}` or `${var:(-3)}`.85- `${var#pat}` — strip shortest prefix match. `${var##pat}` — longest. (Mnemonic: `#` is at start of comments.)86- `${var%pat}` — strip shortest suffix. `${var%%pat}` — longest. (Mnemonic: `%` is at the end.)87- `${var/pat/repl}` — replace first match. `${var//pat/repl}` — replace all. `${var/#pat/repl}` — match at start. `${var/%pat/repl}` — match at end.88- `${var^^}` / `${var,,}` — upper/lower (bash 4+). `${var^}` / `${var,}` — first char only.89- `${!prefix*}` / `${!prefix@}` — names of variables matching prefix. `${!arr[@]}` — array indices/keys.9091## Arrays9293- Indexed: `arr=(a b "c d")`. Access: `${arr[0]}`, all elements `"${arr[@]}"`, indices `"${!arr[@]}"`, count `${#arr[@]}`.94- Associative: `declare -A m; m[key]=val` — **bash 4.0+**. macOS default bash is 3.2.57 (Apple stuck at last GPLv2 release) — `brew install bash` for 5+ or fall back to parallel indexed arrays.95- Append: `arr+=(d e)`. Single-element append: `arr+=(x)` not `arr+=x` (the latter concatenates to element 0).96- `"${arr[@]}"` — each element a separate word. `"${arr[*]}"` — single string joined by `IFS[0]`. Different.97- `mapfile -t lines < file` (or `readarray`) — read file into array, one line per element, `-t` trims newline. Bash 4+.98- A **bare `arr=value`** assigns to index 0 of an existing array — silent footgun if you meant to overwrite.99100## Trap semantics101102- Syntax: `trap 'cmd' EXIT INT TERM HUP ERR`. **Last `trap` for a signal wins** — they don't stack. To chain, read `trap -p SIG` and prepend.103- `EXIT` fires on **any** exit (clean, error, signal-after-handler). Idiomatic cleanup:104 ```bash105 tmpdir=$(mktemp -d)106 trap 'rm -rf -- "$tmpdir"' EXIT107 ```108- `ERR` fires only when `set -e` would. Without `set -E` (errtrace), it does **not** fire inside functions, command substitutions, or subshells — even though the failing command is "in" the script.109- `set -T` (functrace) similarly inherits `DEBUG` and `RETURN` traps into functions.110- Subshells (`( cmd )`, command substitution, pipeline segments) reset signal traps to their inherited disposition unless `-E`/`-T` set; `INT`/`TERM` are reset to default unless explicitly re-trapped.111- Ignore a signal during a critical section: `trap '' INT; critical_cmd; trap - INT` (`-` restores default).112113## Process substitution and pipelines114115- `<(cmd)` — bash makes `cmd`'s stdout available as a file path (`/dev/fd/N` or named FIFO). `>(cmd)` — same but for stdin. **Not POSIX** — bash/ksh/zsh only; don't use under `#!/bin/sh`.116- Exit status of the substituted command is **not propagated**. `cat <(false)` — `cat` reports success.117- Use it to fix the **subshell-loop trap**: `cmd | while read x; do count=$((count+1)); done` runs the loop in a subshell — `count` outside is unchanged. Rewrite as `while read x; do ...; done < <(cmd)` to keep the loop in the parent shell.118- Alternative: `shopt -s lastpipe` (bash 4.2+, only when job control is off — i.e. non-interactive scripts) makes the last pipeline segment run in the parent.119120## Globbing options121122- Default: a glob with no matches expands to the **literal pattern** — `for f in *.txt` iterates once with `f="*.txt"` if no `.txt` files exist.123- `shopt -s nullglob` — unmatched glob expands to nothing. Loop iterates zero times. Sane default for scripts.124- `shopt -s failglob` — unmatched glob is a hard error.125- `shopt -s globstar` — `**` matches recursively. Bash 4+.126- `shopt -s extglob` — `?(...)`, `*(...)`, `+(...)`, `@(...)`, `!(...)` extended patterns.127- `shopt -s dotglob` — globs match dotfiles.128129## Common dangerous patterns130131- `cmd | while read; do ...; done` — loop runs in subshell; mutations don't escape. Fix: `while read; do ...; done < <(cmd)` or `shopt -s lastpipe`.132- `eval "$user_input"` — almost always wrong. Replace with arrays, `declare -n` namerefs (bash 4.3+), or restructure.133- `[ "$a" = "$b" -a "$c" = "$d" ]` — `-a`/`-o` are deprecated and ambiguous. Use `[ "$a" = "$b" ] && [ "$c" = "$d" ]` or `[[ $a = $b && $c = $d ]]`.134- `cd $dir; rm -rf *` without quotes — word-splits and globs `$dir`. Quote: `cd "$dir"`.135- `A && B || C` is **not** if-then-else. If `A` succeeds and `B` fails, `C` runs anyway. Use a real `if`.136- `local var=$(cmd)` — `local` masks `cmd`'s exit. Split into two lines.137- `echo -n` / `echo -e` — non-portable (BSD `echo` differs). Use `printf '%s' "$var"` / `printf '%s\n' "$var"`.138- `find . -name '*.tmp' | xargs rm` — breaks on filenames with spaces/newlines. Use `find ... -print0 | xargs -0 rm` or `find ... -delete` or `find ... -exec rm {} +`.139- Backticks. Use `$( )`.140141## Locale and portability142143- `LC_ALL=C sort` (and `grep`, `comm`, `uniq`, `awk`) — deterministic byte-order, faster, avoids locale-dependent collation surprises (`A` vs `a` ordering, `[a-z]` not matching what you expect).144- `LC_NUMERIC=C printf '%f' 1.5` — decimal point stays `.`. Without it, locales like `de_DE` use `,` and `printf` errors.145- `#!/bin/bash` vs `#!/usr/bin/env bash` — the latter follows `$PATH`, picks up `brew install bash`'s 5.x on macOS instead of the 3.2.57 in `/bin/bash`.146- macOS-shipped bash is **3.2.57** — no associative arrays, no `mapfile`, no `${var^^}`, no `**` globstar, no `&>>`. Either require bash 4+ in your shebang/preamble or write to 3.2 floor.147148## Tooling149150- **ShellCheck** — run on every script. Top rules to internalise:151 - `SC2086` — quote variable expansions to prevent globbing/word-splitting.152 - `SC2046` — quote command substitution to prevent same.153 - `SC2155` — declare-and-assign masks exit code (`local x=$(cmd)`).154 - `SC2015` — `A && B || C` is not if-then-else.155 - `SC2128` — using array name without index gets element 0 only.156 - `SC2178` — assigning string to array variable.157 - `SC2207` — use `mapfile` or array assignment instead of `arr=( $(cmd) )`.158 - `SC2034` — variable defined but never used (often a typo).159- **shfmt** — formatter; pin a version in CI, run `shfmt -d` for diff-only check.160- **bats-core** — bash test framework when scripts get non-trivial.161- `set -x` — trace. Pair with `PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: '` for richer output.162163## Authoritative references164165**Official Bash manual** (`gnu.org/software/bash/manual`):166- [Shell Parameter Expansion](https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html)167- [The Set Builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html)168- [Arrays](https://www.gnu.org/software/bash/manual/html_node/Arrays.html)169- [The Shopt Builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html)170171**Greg's Wiki / BashFAQ** (`mywiki.wooledge.org`):172- [BashFAQ/105 — Why doesn't `set -e` do what I expected?](https://mywiki.wooledge.org/BashFAQ/105)173- [BashPitfalls](https://mywiki.wooledge.org/BashPitfalls)174- [IFS](https://mywiki.wooledge.org/IFS)175- [BashFAQ/100 — String manipulation](https://mywiki.wooledge.org/BashFAQ/100)176- [SignalTrap](https://mywiki.wooledge.org/SignalTrap)177- [ProcessSubstitution](https://mywiki.wooledge.org/ProcessSubstitution)178- [ArithmeticExpression](https://mywiki.wooledge.org/ArithmeticExpression)179- [BashGuide/Practices](https://mywiki.wooledge.org/BashGuide/Practices)180181**ShellCheck wiki** — every `SCxxxx` has its own page at `shellcheck.net/wiki/SCxxxx` (e.g. [SC2155](https://www.shellcheck.net/wiki/SC2155), [SC2015](https://www.shellcheck.net/wiki/SC2015)).182183## Guardrails184185Before recommending a non-trivial bash pattern (strict-mode preamble, trap, array idiom, parameter-expansion trick):1861871. Quote the **specific** option / builtin / expansion form by name.1882. State the **bash version floor** if it matters (3.2 macOS / 4.0 / 4.3 / 4.4 / 5.0).1893. Cite the BashFAQ / Bash manual / ShellCheck rule for non-obvious behaviour.1904. Run `shellcheck` on the produced script and address every finding — silence only with an inline `# shellcheck disable=SCxxxx` plus a one-line reason.191192**A bash script that passes ShellCheck is the floor, not the ceiling.**