Skip to content
Back to skills

Linux Shell Scripting And Userland

CSecurity

Use when writing, reviewing, or debugging shell scripts, or choosing a shell. Covers the expansion order and the traps ranked by damage (word splitting, globbing, the limits of set -e, rm -rf with an unset variable), parameter expansion, POSIX sh vs bash vs zsh vs fish vs nushell and bash 5.3, the defensive script template (set -Eeuo pipefail, ERR traps and cleanup, validated inputs, atomic file replacement, real locks, retry with backoff), the safety cases, and the coreutils and systemd user...

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 19, 2026
ai-agentstypescriptpythonrustgoshellbashsqldebuggingperformancedocumentation

Works with

  • terminal

Security analysis

C73/100
  • criticalAccesses sensitive system or user directories
  • highPerforms destructive filesystem operations

Pro shows the line behind each finding and how to fix it

Scanned September 19, 2026

npx -y skills add the-vibey-project/vibey --skill linux-shell-scripting-and-userland --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Linux Shell Scripting And Userland?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Linux Shell Scripting And Userland
[![Security: C — Skills Directory](https://www.skillsdirectory.com/api/skills/the-vibey-project-linux-shell-scripting-and-userland/badge)](https://www.skillsdirectory.com/skills/the-vibey-project-linux-shell-scripting-and-userland)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: linux-shell-scripting-and-userland
description: "Use when writing, reviewing, or debugging shell scripts, or choosing a shell. Covers the expansion order and the traps ranked by damage (word splitting, globbing, the limits of set -e, rm -rf with an unset variable), parameter expansion, POSIX sh vs bash vs zsh vs fish vs nushell and bash 5.3, the defensive script template (set -Eeuo pipefail, ERR traps and cleanup, validated inputs, atomic file replacement, real locks, retry with backoff), the safety cases, and the coreutils and systemd userland tools worth genuinely knowing."
---

# Linux Kernel & Shell: Shell Semantics, Choosing a Shell, Defensive Scripting, and the Userland

> **Part 3 of 5** of the *OS Development — Linux Kernel and Shell* reference (plugin `os-development-kernel-shell`), covering §8–§11. Sibling skills: `linux-kernel-architecture-and-code` (§0–§3), `linux-syscalls-ebpf-boot-and-init` (§4–§7), `linux-kernel-debugging-process-and-hardening` (§12–§14), `linux-kernel-shell-reference` (§15–§20). Section numbers are shared across the set; a reference written as §N → `skill` points into that sibling skill.
>
> **Currency:** Verified August 2026. See §17 → `linux-kernel-shell-reference` for the currency snapshot and what goes stale first.

> **How to read this.** Reference, not tutorial. Sections are independent. Three markers:
> - **[DURABLE]** — architecture, algorithms, or semantics that have been stable for
>   decades and will outlive this document.
> - **[VERSIONED]** — depends on a kernel version, a shell version, or a distro. Check
>   `Documentation/`, the man page, or the source for your version.
> - **[CONTESTED]** — the kernel community itself disagrees, publicly and at length.
>   Both cases given.
>
> **⚠️ GOTCHA** boxes mark the mistakes that produce kernel oopses, data loss, silent
> corruption, or a patch rejected on the mailing list.
>
> **The framing that matters most:** the kernel is not "a big program." It is a
> **hostile-input state machine running with no memory protection, no exceptions, a
> hard limit on stack depth, and a promise never to break userspace**. Almost every
> kernel-specific rule below descends from one of those five facts.

---

## §8. Shell Semantics — the part that causes the bugs

**[DURABLE] The shell is a macro language over process execution, and its evaluation
order is the source of nearly every shell bug.** Learn the order and most mysteries
dissolve.

### 8.1 The expansion order

For each command word, in this exact sequence:
```
1. Brace expansion            {a,b}  {1..5}          (bash/zsh; NOT POSIX)
2. Tilde expansion            ~  ~user
3. Parameter expansion        $var  ${var:-def}  ${var#pat}  ${var//a/b}
   Command substitution       $(cmd)   `cmd`
   Arithmetic expansion       $(( ... ))
4. WORD SPLITTING             ← splits UNQUOTED results on $IFS   ★ the bug lives here
5. Pathname expansion (glob)  *  ?  [abc]           ← on UNQUOTED results   ★ and here
6. Quote removal
```
**Steps 4 and 5 do not happen inside double quotes.** That single fact is why the rule is
"quote everything."

```bash
file="my report.txt"
rm $file        # → rm my report.txt   → tries to remove TWO files. Data loss.
rm "$file"      # → rm 'my report.txt' → correct.

files=$(ls)     # word-splits AND globs the output. Filenames with spaces or * break it.
                # Also: NEVER parse ls. Use a glob or find -print0.
```

### 8.2 The traps, ranked by how much damage they cause

> **⚠️ 1. Unquoted variables.** Covered above. `shellcheck` flags every instance.
> This is the number one shell bug and it is 100% mechanically detectable.

> **⚠️ 2. `$@` vs `$*`.** `"$@"` expands to one word per argument, preserving them
> exactly. `"$*"` joins them into a single word with the first `$IFS` char. **Always
> `"$@"`, always quoted.** Unquoted `$@` word-splits again and defeats the purpose.

> **⚠️ 3. The pipeline exit status.** By default `$?` is the **last** command's status:
> `false | true` succeeds. `set -o pipefail` makes the pipeline fail if any element
> fails. `${PIPESTATUS[@]}` (bash) has the individual statuses.

> **⚠️ 4. Pipelines create subshells.** `... | while read x; do count=$((count+1)); done`
> leaves `count` unchanged in bash, because the loop ran in a subshell. Fix with process
> substitution: `while read x; do ...; done < <(cmd)`, or `shopt -s lastpipe`.
> **zsh does not have this problem** — it runs the last pipeline element in the current
> shell.

> **⚠️ 5. `set -e` does not do what you think.** It is full of exceptions: it doesn't
> trigger for commands in a condition (`if`, `while`, `&&`, `||`), or for any command
> except the last in a pipeline (without `pipefail`), or inside a function called in a
> condition context — where it is disabled for the *entire* function. It is a useful
> default, **not** a substitute for checking return codes on anything that matters.
> The `set -e` critique (the "BashFAQ 105" position) is worth reading before relying on it.

> **⚠️ 6. `[` vs `[[`.** `[` is the `test` command — its arguments undergo word splitting
> and globbing, so `[ $x = y ]` breaks when `$x` is empty or has spaces. `[[ ]]` is shell
> syntax (bash/zsh/ksh, **not POSIX**) with no splitting inside, plus `=~` and `&&`.
> **Use `[[ ]]` in bash/zsh; quote religiously in POSIX `sh`.**
> Note also: **POSIX.1-2024 removed `-a` and `-o` from `test`** — use `&&`/`||` between
> separate `[ ]` invocations.

> **⚠️ 7. Arithmetic and leading zeros.** `$((08))` is an error in bash — leading zero
> means octal. Bites date handling every August and September. Use `10#$var`.

> **⚠️ 8. `read` mangles input by default.** `read -r` (don't interpret backslashes),
> `IFS= read -r line` (don't strip leading/trailing whitespace). The canonical loop is
> `while IFS= read -r line; do ...; done < file`.

> **⚠️ 9. Filenames can contain anything except `/` and NUL** — including newlines,
> spaces, and leading `-`. Use `find -print0 | xargs -0`, or `find -exec ... +`, and
> `--` to end option parsing (`rm -- "$f"`).

> **⚠️ 10. `cd` can fail.** `cd /some/dir; rm -rf *` deletes the wrong thing when the
> `cd` fails. Always `cd /some/dir || exit 1`.

### 8.3 Parameter expansion — the underused half of the language

```bash
${var:-default}   # use default if unset/empty (doesn't assign)
${var:=default}   # assign default if unset/empty
${var:?message}   # ERROR OUT if unset/empty  ← excellent for required arguments
${var:+alt}       # use alt only if var IS set
${#var}           # length
${var#prefix}     ${var##prefix}    # strip shortest / longest matching prefix
${var%suffix}     ${var%%suffix}    # strip shortest / longest matching suffix
${var/old/new}    ${var//old/new}   # replace first / all (bash/zsh, not POSIX)
${var:offset:len}                   # substring
${array[@]}  ${#array[@]}  ${!array[@]}   # elements, count, indices
```
`${var#*/}` and `${var%/*}` replace `basename`/`dirname` without forking a process —
meaningful inside a loop.

---

## §9. Choosing a Shell

### 9.1 The comparison

| | **bash** | **zsh** | **fish** | **nushell** | **POSIX sh** (dash) |
|---|---|---|---|---|---|
| Version (Aug 2026) | **5.3** (July 2025) | **5.9.1** (May 2026) | **4.7** (May 2026) | 0.111+ (pre-1.0) | — |
| Written in | C | C | **Rust** (since 4.0) | Rust | C |
| Default on | Most Linux distros | **macOS since Catalina** | none | none | Debian `/bin/sh` |
| POSIX-compatible | Yes (mostly) | Mostly | **No, by design** | **No** | Yes, by definition |
| Runs bash scripts | — | Mostly unmodified | No | No | Only POSIX ones |
| Interactive out of box | Poor | Good with plugins | **Excellent** | Good | Minimal |
| Structured data | No | No | No | **Yes — tables, JSON, SQLite natively** | No |
| Best for | **Scripts, portability, servers** | Daily interactive on macOS/Linux with bash compat | Best interactive UX | Data wrangling in the terminal | `#!/bin/sh` scripts, containers |

**Adoption reality**: Stack Overflow's 2025 survey put Bash/shell scripting at **49% of
developers**, ranking fifth among all languages, ahead of TypeScript. Apple's 2019 switch
of the macOS default to zsh is the single largest driver of zsh adoption. Fish sits around
7% among developers.

### 9.2 The version that matters: bash 5.3

**[VERSIONED] Bash 5.3 (3 July 2025)** — the first release in three years. What's
actually new and useful:
- **`${ command; }`** — command substitution that **runs in the current shell context, no
  fork**, capturing stdout. And **`${| command; }`**, which runs in the current shell and
  leaves the result in `REPLY`. This is the headline feature and it matters for
  performance in loops.
- **`GLOBSORT`** — control pathname-completion/glob sort order by name, size, blocks,
  mtime, atime, ctime, numeric, or none, ascending or descending.
- `source -p PATH` to search a given PATH instead of `$PATH`.
- **Executing the RHS of `&&`/`||` no longer forks when unnecessary** — a real scripting
  speedup.
- `local -` saves and restores single-letter shell options for the function's duration.
- `compgen`/`complete -o nosort`; `wait` can now wait on the most recent process
  substitution; `unset var[0]` behaves like `${var[0]}`.
- **Source updated for C23 conformance — bash no longer compiles with K&R C compilers.**
- Readline 8.3 alongside: case-insensitive search option, `export-completions`,
  `force-meta-prefix`, `rl_print_keybinding`.

> **⚠️ GOTCHA — macOS ships bash 3.2.** Apple has not shipped a newer bash since the
> GPLv3 licence change in 2007. Any script relying on associative arrays (bash 4.0),
> `${var,,}` case conversion (4.0), `readarray`/`mapfile` (4.0), or `${ ... }` (5.3) will
> fail on a stock Mac. Either target POSIX sh, or require `brew install bash` and use
> `#!/usr/bin/env bash`.

### 9.3 The pragmatic answer

**[DURABLE, and the consensus across essentially every practitioner comparison]:**
> **Use whatever you like interactively. Write bash (or POSIX sh) for anything that has
> to run somewhere else.**

fish and nushell are genuinely better interactive shells and genuinely worse scripting
targets — fish requires rewriting with `set`, `if…end`, and function-scoped variables;
nushell requires rethinking (no subshells, no `$(...)`, structured pipelines). The
recurring, practical friction with fish: version managers (nvm, rbenv, pyenv, asdf,
sdkman) all ship bash/zsh hooks, and fish users depend on community wrappers that lag.
And — a distinctly 2026 problem — **AI coding agents shell out to the system default
shell**, so a non-POSIX default shell introduces a whole class of tooling breakage.

For **portable scripts**, target POSIX `sh` and test with `dash`. Bash-isms silently work
on a bash-as-/bin/sh system and break on Debian/Ubuntu where `/bin/sh` is `dash`, and in
Alpine containers where it's busybox `ash`. `shellcheck -s sh` catches this.

---

## §10. Defensive Shell Scripting

### 10.1 The template

```bash
#!/usr/bin/env bash
# Purpose: one line. Usage: script.sh <input> [output]
set -Eeuo pipefail
#   -E  ERR trap is inherited by functions and subshells
#   -e  exit on error (KNOW ITS LIMITS — §8.2 trap 5)
#   -u  error on unset variable      ← catches the rm -rf "$TYPO/" class
#   -o pipefail  a pipeline fails if ANY element fails
IFS=$'\n\t'                          # remove space from IFS: safer word splitting

readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"

# --- cleanup that actually runs, on every exit path -------------------------
readonly TMPDIR_="$(mktemp -d)"      # mktemp -d, never a predictable /tmp path
cleanup() {
    local rc=$?
    rm -rf -- "$TMPDIR_"
    exit "$rc"
}
trap cleanup EXIT                    # EXIT fires for normal exit, set -e, and signals
trap 'die "interrupted"' INT TERM

die()  { printf '%s: %s\n' "${0##*/}" "$*" >&2; exit 1; }
log()  { printf '[%s] %s\n' "$(date -Is)" "$*" >&2; }   # logs to stderr, not stdout

# --- required inputs, validated ---------------------------------------------
: "${INPUT_FILE:?INPUT_FILE must be set}"        # ${:?} is the cheapest validation
[[ -r $INPUT_FILE ]] || die "cannot read: $INPUT_FILE"

need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; }
need jq; need curl

main() {
    local out="${1:-/dev/stdout}"
    # ... work ...
}
main "$@"
```

### 10.2 The rules

1. **`shellcheck` in CI, non-negotiable.** It is the single highest-value tool in shell
   development and catches the entire quoting class mechanically. `shfmt` for formatting.
2. **Quote every expansion.** `"$var"`, `"$@"`, `"${arr[@]}"`. The exceptions are rare
   and deliberate.
3. **`local` every function variable.** Shell variables are global by default; a function
   that forgets `local i` will silently corrupt its caller's loop.
4. **Never parse `ls`.** Use globs (`for f in ./*.txt`) or `find -print0 | while IFS= read
   -r -d '' f`.
5. **`--` before user-controlled arguments**: `rm -- "$f"`. A file named `-rf` is legal.
6. **Prefix globs with `./`**: `rm ./*` not `rm *`, for the same reason.
7. **`mktemp`, always.** Predictable temp paths are a symlink-attack primitive.
8. **`trap ... EXIT` for cleanup**, not cleanup code at the end (which `set -e` skips).
9. **Log to stderr; put data on stdout.** This is what makes a script composable.
10. **Exit codes mean something**: 0 success, 1 general, 2 usage, 126 not executable,
    127 not found, 128+N killed by signal N.
11. **Idempotence.** Assume the script will be re-run after failing halfway.
12. **`set -x` / `PS4='+ ${BASH_SOURCE}:${LINENO}: '`** for tracing, and
    `bash -n script.sh` for a syntax-only check.
13. **When it exceeds ~200 lines or needs real data structures — stop and use Python.**
    This is the most commonly ignored and most valuable rule in the list.

### 10.3 The safety cases

```bash
# THE classic. With set -u, a typo'd or empty variable exits instead of deleting /.
rm -rf "${BUILD_DIR:?BUILD_DIR not set}"/*

# Confirm before anything destructive when interactive
if [[ -t 0 ]]; then read -r -p "Delete ${count} files? [y/N] " a; [[ $a == [yY] ]] || exit 1; fi

# Atomic file replacement — same recipe as §1.5, in shell
tmp="$(mktemp -- "${target}.XXXXXX")"
generate > "$tmp" && mv -- "$tmp" "$target"     # mv within a filesystem is atomic

# Concurrency: a real lock, not a PID file
exec 9>/var/lock/myjob.lock
flock -n 9 || die "already running"

# Retry with backoff
for i in 1 2 4 8 16; do curl -fsS "$url" && break; sleep "$i"; done
```

---

## §11. The Userland

### 11.1 Tools worth genuinely knowing

| Domain | Tools |
|---|---|
| Text | `grep`(`-r -n -F -w -o -P`), `sed`, `awk`, `cut`, `tr`, `sort`(`-u -n -k -t`), `uniq -c`, `join`, `comm`, `paste`, `column -t`, `fmt` |
| Structured | **`jq`** (JSON), `yq`, `xmlstarlet`, `csvkit`, `miller`(mlr) |
| Files | `find`(`-print0 -exec +`), `xargs -0 -P`, `rsync -aHAX --delete`, `stat`, `install`, `readlink -f` |
| Processes | `ps aux`, `pgrep/pkill`, `kill -l`, `nohup`, `setsid`, `timeout`, `nice`, `ionice`, `taskset`, `chrt` |
| Observability | `top`/`htop`/`btop`, `iostat`, `vmstat`, `mpstat`, `pidstat`, `sar`, **`ss`** (not `netstat`), **`ip`** (not `ifconfig`), `lsof`, `fuser`, `dstat` |
| Introspection | `strace`, `ltrace`, `perf`, `bpftrace`, `/proc/PID/{maps,status,fd,stack,limits}` |
| Disks | `lsblk`, `blkid`, `df -h`, `du -sh`, `ncdu`, `smartctl`, `fio`, `badblocks` |
| Build/pack | `make`, `ninja`, `meson`, `cmake`, `pkg-config`, `dpkg/rpm`, `ldd`, `objdump`, `readelf`, `nm`, `strings` |
| Modern rewrites | `rg`(ripgrep), `fd`, `bat`, `eza`, `delta`, `zoxide`, `atuin`, `starship`, `dust`, `sd` |

**[DURABLE] The pipeline idioms that never stop being useful:**
```bash
sort | uniq -c | sort -rn | head          # frequency ranking. Works on anything.
awk '{s+=$3} END {print s}'               # sum a column
awk -F: '$3 >= 1000 {print $1}' /etc/passwd
find . -type f -print0 | xargs -0 -P"$(nproc)" -n1 process   # parallel over files
comm -13 <(sort a) <(sort b)              # lines in b not in a (process substitution)
```

**⚠️ GNU vs BSD vs busybox.** `sed -i` takes an argument on macOS/BSD and not on GNU;
`date` options differ completely; `readlink -f` doesn't exist on old macOS. **Test the
target's userland, or install GNU coreutils, or stay strictly POSIX.** Note also that
Ubuntu has begun shipping **uutils** (a Rust coreutils reimplementation) — behavioural
differences from GNU coreutils are a new source of surprises.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…