From 48015c7c3d905121e9e3f86a17db25a5396a57e8 Mon Sep 17 00:00:00 2001 From: Konrad Heimel Date: Sun, 23 Aug 2026 17:52:09 +0200 Subject: [PATCH 1/4] :lock: fix(hack): refuse to run gate scripts under a too-old bash (BASH32-F01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/bin/bash hack/docs/truthlag_pins_test.sh` on stock macOS printed 20 PASS lines, died at its `declare -A`, never printed its final `OK:` banner — and EXITED 0. `task docs-gates`, and therefore `task check`, read that as a green gate. AGENTS.md rule 4 makes a green local `task check` a per-commit precondition, so this was a live LOCAL trust hole; CI is ubuntu/bash 5, which is exactly why it survived unnoticed. Mechanism, measured on bash 3.2.57: 3.2 has no associative arrays, so under `set -u` it re-reads `declare -A x=([k]=v)` as an INDEXED array assignment and evaluates the subscript arithmetically, hitting "unbound variable"; the shell terminates the script and the exit status is 0. The two sibling scripts fail CLOSED, but only incidentally: `hack/release/verify-artifacts.sh:202`'s literal is EMPTY, so 3.2 degrades it to `declare: -A: invalid option` and `set -e` turns that into exit 2 (measured with a --dist that actually reaches line 202); `hack/validate-schemas-stock.sh` loses `mapfile` to "command not found" and exits 1. Populating an empty literal or dropping `-e` converts either into the silent case, so the floor is now declared rather than inferred from how one construct happens to degrade. `hack/lib/require-bash.sh` takes the floor as a PARAMETER — 4.0 for `declare -A`, 4.4 for `mapfile -d ''` — because a blanket floor would refuse shells that can run the 4.0 gates and would hide which construct binds. It is bash-3.2-safe by construction: it is sourced by scripts whose whole job under 3.2 is to refuse cleanly. The lib is resolved script-relative, then from the enclosing git checkout, because hack/release/install_cosign_pin_test.sh executes mutated COPIES of verify-artifacts.sh out of a mktemp dir; unresolvable means refuse. D-154 records the decision and the two disproved theories (EXIT-trap status swallow, `set -e`). The enforcing gate lands next. --- docs/decisions/decisions.md | 1 + hack/docs/truthlag_pins_test.sh | 21 ++++++ hack/lib/require-bash.sh | 108 +++++++++++++++++++++++++++++++ hack/release/verify-artifacts.sh | 21 ++++++ hack/validate-schemas-stock.sh | 18 ++++++ openspec/specs/backlog.md | 1 + 6 files changed, 170 insertions(+) create mode 100644 hack/lib/require-bash.sh diff --git a/docs/decisions/decisions.md b/docs/decisions/decisions.md index a255d3a4..dedf4a8f 100644 --- a/docs/decisions/decisions.md +++ b/docs/decisions/decisions.md @@ -158,4 +158,5 @@ project/process decisions. | D-151 | 2026-08-16 | **OpenSSF Best Practices project creation is RESOLVED: (a) — the operator will create the `bestpractices.dev` project for `PlatformRelay/assent`.** Unblocks SEC-SC-S02 (evidence page + README badge, once *passing*); SEC-SC-S01 (fuzzing) is not blocked by this and can proceed independently. Operator action only — requires a personal account at bestpractices.dev, not automatable in-tree. Current CII score is 0, which drags the OpenSSF Scorecard (~7); the repo already satisfies most criteria (pinned CodeQL, Dependabot, secret scanning, signed releases, SLSA provenance). Revert: leave CII at 0 indefinitely — SEC-SC-S01 is unaffected either way. | | D-152 | 2026-08-18 | **The 2026-08-18 audit's "Next (risk reduction)" wave is decomposed as its own epic P5-AUD2 (`openspec/specs/p5-aud2-audit-remediation/spec.md`), five stories, spec-first.** Context: `agent-context/PROJECT-AUDIT-2026-08-18.md` closed both P1 conditions the same day and v0.3.0 shipped, leaving a named but untracked wave — exec-transport trio (REL-01/02/07), REL-03 `ErrNotFound` discrimination, SEC-03 cosign identity pin, TEST-02 mutant. Options considered: **(a)** hand-fix them as loose commits (rejected — AGENTS.md rule 4 is spec-first, and REL-01 is now byte-identical across *three* audits precisely because nothing ever tracked it); **(b)** append them to the existing P5-AUD epic (rejected — that epic's exit gate is CLOSED and reopening it would make "AUD complete" a moving claim); **(c)** a new AUD2 epic keyed to this audit **(chosen)**. Three consequences recorded here so they are not re-litigated: **(1)** AUD2 has **no release-condition story** — the audit had exactly two P1s and both are already closed; **(2)** **WG-S01 is deliberately excluded** — it carries the LGTM governance marker, and GOVERNANCE says such stories are surfaced to the maintainer, which an autonomous loop's decide-and-log does not override; **(3)** AUD2's exit gate is a **`task check` stage**, not a `release-exitgate` step, because that job is `pull_request`-skipped (RELSE-08) and wiring the gate there is how AUD-S18's own stale `CHECK_STAGES` pin survived four merges. A 2026-08-10-keyed AUD2 draft exists only in the local stash `leave-aud2-not-this-epic`, was never committed, and is superseded; its still-open items (F3/F5/F7) stay Later-wave. Revert: delete the spec + backlog section; the four findings return to the audit report untracked. | | D-153 | 2026-08-19 | **The cosign signer-identity pin published in `SECURITY.md` was WRONG, not merely missing from `hack/install.sh`: the identity regexp is widened to `^https://github\.com/PlatformRelay/[Aa]ssent/` in both files (AUD2-S03 / SEC-03).** Substance first: `SECURITY.md`'s "Verify a tagged release" instructions pinned `--certificate-identity-regexp '^https://github.com/PlatformRelay/assent/'`, and an adopter who followed them on **v0.2.0 or v0.3.0 got a verification FAILURE on a genuine, correctly signed artifact** — the published recipe has been broken for every release since v0.2.0, and the natural reading of that failure is "this release was tampered with". Cause: the repository was renamed `PlatformRelay/assent` → `PlatformRelay/Assent` between v0.1.0 and v0.2.0; the keyless signing certificate's Subject Alternative Name carries GitHub's canonical casing; cosign compiles `--certificate-identity-regexp` as a Go RE2 pattern and matches it **case-sensitively**. Decoded from the published bundles: v0.3.0 and v0.2.0 sign as `https://github.com/PlatformRelay/Assent/.github/workflows/release.yaml@refs/tags/vX.Y.Z`, v0.1.0 as `https://github.com/PlatformRelay/assent/.github/workflows/release.yaml@refs/heads/main`. All three verify under the new value; independently re-confirmed with real cosign against the real v0.3.0 artifact (`Verified OK` under the new pin, exit 1 "expected SAN value to match regex" under the old). Options considered: **(a)** ship the pin byte-identical to the published-but-broken value and file the breakage as a follow-up (rejected — it would land a green gate asserting a guarantee that fails closed on this project's own releases, D-124's defect one level up); **(b)** case-insensitive `(?i)` (rejected — wider than the defect and it silently accepts casings GitHub never issues); **(c)** an explicit `[Aa]` class in both files, dots escaped (chosen). Not widened otherwise: the `^` anchor and the owner/repo scope stand, so another owner, an `assent-mirror` typosquat, another forge and an unescaped-dot host all still fail. Enforcement, per D-128: **one** published truth — `hack/release/install_cosign_pin_test.sh` extracts the pair from `hack/install.sh` and `SECURITY.md`, requires exactly one distinct value per file, reddens on drift, and (this is the assertion that would have caught the defect) matches the pin against the three **real** SANs above, committed as offline fixtures, plus six negatives. Extends D-110 (bundles beside archives) and D-109; the maintainer-path twin at `hack/release/verify-artifacts.sh:124` is still unpinned and is tracked in the backlog, not fixed here. Revert: restore `'^https://github.com/PlatformRelay/assent/'` in both files — which re-breaks verification of v0.2.0 and every later release, so revert only together with a repo rename back to lowercase. | +| D-154 | 2026-08-23 | **Every `hack/**` script that uses a bash 4+ feature declares a PER-SCRIPT version floor via the shared `hack/lib/require-bash.sh`, because one of them was a silent local FAIL-OPEN under stock macOS bash 3.2 (BASH32-F01).** Substance first: `/bin/bash hack/docs/truthlag_pins_test.sh` on macOS printed 20 `PASS` lines, died at its `declare -A ex_s09_prefix_hint=(…)`, never printed its final `OK: all truth-lag pins green` banner — and **exited 0**. Its caller `task docs-gates`, and therefore `task check`, read that as a green gate, and AGENTS.md rule 4 makes a green local `task check` a per-commit precondition; CI is ubuntu/bash 5, so nothing merges through this path, which is precisely why it survived. Mechanism, measured on bash 3.2.57: 3.2 has no associative arrays, so under `set -u` it re-reads `declare -A x=([k]=v)` as an INDEXED array assignment and evaluates the subscript `k` **arithmetically**, hitting "unbound variable"; the shell terminates the script and the exit status is 0. Two theories were disproved en route and are recorded so they are not re-explored: the `trap … EXIT` status-swallow (3.2 propagates 1 through an EXIT trap correctly) and `set -e` (the script runs `set -uo pipefail`). The other two affected scripts fail CLOSED, but only incidentally: `hack/release/verify-artifacts.sh:202`'s literal is EMPTY, so there is no subscript to evaluate and it degrades to `declare: -A: invalid option`, which its `set -e` turns into exit 2 (measured with a `--dist` that reaches line 202 — an unreachable dist exits 1 much earlier); `hack/validate-schemas-stock.sh`'s `mapfile` degrades to "command not found" and it exits 1. Populating an empty literal or dropping `-e` silently converts a closed failure into the open one, so "how this construct happens to degrade" is not a guarantee worth depending on. Options considered: **(a)** one blanket repo-wide floor (rejected — the binding minimum differs per feature: `declare -A` 4.0, `local -n` 4.3, `mapfile -d ''` 4.4; a blanket floor both refuses shells that could run the 4.0 gates and hides which feature actually binds); **(b)** rewrite the three scripts to be 3.2-compatible (rejected — associative arrays and `mapfile` are the right tools here, a rewrite is a large risky change to scripts whose correctness IS the product, and it does nothing about the next gate author); **(c)** copy the existing inline `BASH_VERSINFO` guard from `hack/audit/aud2_exitgate_test.sh:71` into each script (rejected as the *only* measure — it duplicates the explanation three more times and still leaves the next author to rediscover the hazard); **(d) chosen** — a shared `require_bash [.] ` helper, called with each script's own correct floor (4.0 / 4.0 / 4.4), plus a meta-gate. Enforcement, per D-128, is one place: `hack/lint/bash_version_guard_test.sh`, wired as `task check` stage **20** and pinned in `hack/audit/exitgate_test.sh`'s `CHECK_STAGES` in the same commit. It scans every `hack/**/*.sh` for command-position bash 4+ constructs, requires a guard on each, requires the declared floor to be at least the feature's own minimum, and — the assertion that makes the rest non-vacuous — requires the scan to still find all four known feature-using files, so a typo in a detection pattern reds instead of quietly matching nothing. Its own negative control mutates real copies of the three scripts with the guard lines stripped and asserts they are flagged. `hack/audit/aud2_exitgate_test.sh` keeps its inline guard and is accepted by the gate as guarded; adopting the helper there is a follow-up, not this lane. Stated limits: the gate sees only command-position constructs in `hack/**/*.sh` (not `eval`, dynamically built command names, case-modification expansions, or scripts outside `hack/`), and the exit-0 control needs a real bash 3.2 — present on macOS, absent on CI ubuntu, where it SKIPs **loudly** rather than passing silently. Accepted behaviour change: under 3.2, `hack/release/verify-artifacts.sh --help` now refuses instead of printing usage. Revert: delete the guard calls — `task check` then silently stops certifying the docs truth-lag pins for every contributor whose `bash` is 3.2. | | D-158 | 2026-08-23 | **CI-TOOLCHAIN — `verify` was red on every PR from a golangci-lint/Go skew; the remedy is to bump `GOLANGCI_LINT_VERSION` v2.12.2 → v2.13.1 and KEEP `go-version: stable`.** Symptom: PR #85, a six-file Markdown diff with **zero Go bytes**, failed `verify` with `/opt/hostedtoolcache/go/1.27.0/x64/src/crypto/internal/randutil/randutil.go:11:2: could not import math/rand/v2 (… method must have no type parameters) (typecheck)` — the failing path is inside the **Go toolchain's own source tree**, so no author change can fix it and re-running cannot clear it. `Analyze (go)`, `Analyze (actions)` and `CodeQL` all passed on the same run: the tree compiles clean under Go 1.27, only the linter chokes. **Mechanism**: golangci-lint typechecks the standard library with the `go/types` it was **compiled with**, not the `go` on `PATH`. GitHub's `stable` rolled 1.26 → 1.27.0 between this repo's last green `main` run (2026-08-19) and 2026-08-23; the v2.12.2 release binary is built with go1.26.x and cannot read the 1.27 stdlib. **Reproduced locally, not reasoned** (Go 1.27.0 SDK on `PATH`, `GOTOOLCHAIN=local`, whole tree): v2.12.2 → **panic** in `pkg/goanalysis/runner_loadingpackage.go:335` inside `go/types.(*Checker).Files`, exit 2; official v2.13.1 asset (`built with go1.27.0`) → **0 issues**, exit 0; `golangci-lint config verify` → exit 0, so the v2 config schema in `.golangci.yml` needs no migration; v2.13.1 also → **0 issues** against local Go 1.26.6, so the bump is safe for contributors who have not moved; and `go install …@v2.13.1` under Go 1.27.0 → 0 issues, which is the **`release-exitgate`** path (that job builds the binary rather than using the action, and `if: github.event_name != 'pull_request'` / RELSE-08 means no PR ever exercises it). **Diagnosis heuristic worth keeping**: golangci-lint's `go.mod` carries the comment *"the minimum Go version must always be latest-1"*, so its `go` directive is a machine-readable "supports Go 1.N+1" signal — v2.12.2 says `go 1.25.0` (supports ≤1.26), v2.13.1 says `go 1.26.0` (supports ≤1.27), and upstream commit `42a0530` in v2.13.0 is literally "go1.27 support". **Options considered.** (A) *bump the linter* — CHOSEN: treats the root cause, is a **one-line** diff because the version is single-sourced at workflow scope (D-128), fixes BOTH exposed sites (the `golangci-lint-action` in `verify` and the `go install` in `release-exitgate`) at once, and the risk it carried — new findings from a newer linter blocking every merge, since `golangci-lint` runs inside `task lint` inside `task check` — was **measured at zero** on the whole tree under both Go versions. (B) *pin `go-version: 1.26.x`* — REJECTED as the primary: it needs two pins (`verify.yaml:56` and `:171`), leaves the class of bug live, undercuts the stated reason `stable` is there (`# govulncheck needs it`, with `setup-go` exporting `GOTOOLCHAIN=local` so a pinned toolchain can fail to `go run` a tool whose module requires a newer Go — unverified, and moot under (A)), and trades a loud recurrence for **silent indefinite drift onto an unsupported Go** in a repo whose posture is govulncheck + cosign + SLSA + Scorecard. (C) *both* — REJECTED as gold-plating: (A) alone is measured green, and a second simultaneous remedy would make a still-red PR ambiguous. **Per-file sweep of `go-version: stable`, stated rather than silently skipped:** `verify.yaml:56` and `:171` were the only exposed sites and are fixed by the env bump without touching either line; `schemas.yml:57` (runs `go test` + ajv, no linter), `vulncheck.yaml:30` (govulncheck only — `stable` is actively *desirable* there) and `codeql.yaml:45` (CodeQL, which passed on 1.27.0) carry no golangci-lint and are NOT exposed; `release.yaml` uses `go-version-file: go.mod` and is unaffected. `hack/lint/workflow_pins_test.sh` asserts `TASK_VERSION`, action SHA pins and step wiring but **not** `go-version` or `GOLANGCI_LINT_VERSION`; it passes on this change. **Revert is (B), not bare v2.12.2** — reverting the env line alone restores a known-red state: revert = `GOLANGCI_LINT_VERSION` back to `v2.12.2` **and** pin `verify.yaml:56` + `:171` to `go-version: 1.26.x`. **Residual risk, stated plainly:** when `stable` rolls to Go 1.28, v2.13.1 breaks identically. The repo finds out because `verify` reds on **every** PR including zero-Go diffs — loud and immediate, with the signature recorded in `verify.yaml`'s env comment and the fix being one env line. Nothing detects it *earlier*: dependabot's `github-actions` ecosystem updates `uses:` refs, not `env:` literals (backlog `CI-TOOLCHAIN-F01`). Accepted on purpose over (B)'s failure mode. Note also the pre-existing local/CI linter skew — `Taskfile.yml`'s `lint` task is bare `golangci-lint run ./...` against whatever binary a contributor has installed, so this pin has never governed local `task check`; deliberately not changed here (that file was owned by a concurrent lane). | diff --git a/hack/docs/truthlag_pins_test.sh b/hack/docs/truthlag_pins_test.sh index b39d489d..c1f1ea94 100755 --- a/hack/docs/truthlag_pins_test.sh +++ b/hack/docs/truthlag_pins_test.sh @@ -30,6 +30,27 @@ # paper-gate). Deleting that line from Taskfile.yml reddens the pin below. set -uo pipefail +# BASH >= 4.0 REQUIRED, ASSERTED FIRST — before anything else can partially run (D-154). +# The REQ-EX-S09-02 pins below build an associative array. Stock macOS /bin/bash is 3.2, which +# has none: under `set -u` it re-reads the literal as an INDEXED array assignment, evaluates the +# `[topics/]` subscript arithmetically, dies with "unbound variable" after 20 PASS lines, never +# prints the final `OK:` banner — and EXITS 0. `task docs-gates`, and therefore `task check`, +# read that as a green gate. AGENTS.md rule 4 makes a green local `task check` a per-commit +# precondition, so this was a live LOCAL trust hole, not a theoretical one; CI is ubuntu/bash 5, +# which is exactly why it survived. Measured; pinned by hack/lint/bash_version_guard_test.sh. +# The lib is resolved script-relative first, then from the enclosing git checkout, because other +# gates execute mutated COPIES of these scripts out of a mktemp dir (see the failure that shape +# caused in hack/release/install_cosign_pin_test.sh §5g). Unresolvable => refuse, never proceed. +_assent_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" 2>/dev/null && pwd || true)" +[ -n "$_assent_lib" ] || _assent_lib="$(cd "$(git rev-parse --show-toplevel 2>/dev/null)/hack/lib" 2>/dev/null && pwd || true)" +[ -r "${_assent_lib}/require-bash.sh" ] || { + echo "FAIL: $0 cannot locate hack/lib/require-bash.sh — refusing to run without its bash version floor (D-154)." >&2 + exit 1 +} +# shellcheck source=hack/lib/require-bash.sh +. "${_assent_lib}/require-bash.sh" || exit 1 +require_bash 4.0 "declare -A (associative arrays)" || exit 1 + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT" diff --git a/hack/lib/require-bash.sh b/hack/lib/require-bash.sh new file mode 100644 index 00000000..74cb2d9c --- /dev/null +++ b/hack/lib/require-bash.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# require-bash.sh — a gate script's bash version floor, asserted before it can run. +# +# WHY THIS EXISTS (D-154). Stock macOS /bin/bash is 3.2.57. It has no associative +# arrays and no `mapfile`, and the way it fails differs by construct: +# +# declare -A x=([k]=v) under `set -u` -> 3.2 parses the literal as an INDEXED +# array assignment and evaluates the +# subscript `k` ARITHMETICALLY, hitting +# "unbound variable". The shell +# terminates the script — and the exit +# status is 0. Every caller reads that +# as a green gate. This is a FAIL-OPEN. +# declare -A x=() -> no subscript to evaluate, so it +# degrades to `declare: -A: invalid +# option` (status 2). That fails closed +# only because the script happens to +# run `set -e`. +# mapfile / readarray / local -n -> "command not found". Fails closed. +# +# Only the first shape is silent, but "fails closed for an incidental reason" is +# not a guarantee: dropping `set -e`, or populating an empty literal, converts one +# into the other with no visible signal. So every hack/** script that uses a bash +# 4+ feature declares its own floor here instead of relying on how its particular +# construct happens to degrade. hack/lint/bash_version_guard_test.sh enforces that. +# +# The floor is a PARAMETER, never a blanket: `declare -A` needs 4.0, `local -n` +# needs 4.3, `mapfile -d ''` needs 4.4. A single repo-wide floor would either +# refuse shells that are perfectly capable of running a given gate, or admit +# shells that are not. +# +# THIS FILE IS BASH-3.2-SAFE BY CONSTRUCTION AND MUST STAY THAT WAY. It is sourced +# by scripts whose entire job under 3.2 is to REFUSE cleanly; a bash 4 feature in +# here would reinstate the defect inside the fix. It uses only POSIX-shell `[` +# tests and `${var%%…}` / `${var#…}` expansions, all of which 3.2 has. +# +# USAGE — at the very top of the gate, after `set …` and before anything else: +# +# _assent_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" 2>/dev/null && pwd || true)" +# [ -n "$_assent_lib" ] || _assent_lib="$(cd "$(git rev-parse --show-toplevel 2>/dev/null)/hack/lib" 2>/dev/null && pwd || true)" +# [ -r "${_assent_lib}/require-bash.sh" ] || { echo "FAIL: …" >&2; exit 1; } +# # shellcheck source=hack/lib/require-bash.sh +# . "${_assent_lib}/require-bash.sh" || exit 1 +# require_bash 4.0 "declare -A (associative arrays)" || exit 1 +# +# Two resolution steps, deliberately, and NO environment override: other gates +# execute mutated COPIES of these scripts out of a mktemp dir (see +# hack/release/install_cosign_pin_test.sh §5e-5g), where script-relative +# resolution cannot work and the enclosing git checkout is the only anchor left. +# An env var pointing at an arbitrary directory would be a disarm switch — a +# `require_bash() { return 0; }` on that path turns every guard off — which is the +# `--text-only` defect class this repo already pins against. An unresolvable lib +# means REFUSE; it never means proceed unguarded. +# +# The `|| exit 1` is required on the source and require_bash lines and is pinned +# by the lint gate. Neither is decoration: +# - callers such as hack/docs/truthlag_pins_test.sh run `set -uo pipefail` with +# NO `-e`, so a failed `.` would merely return non-zero and execution would +# carry straight on into the very construct being guarded; +# - if the file loaded but `require_bash` were undefined, the call would return +# 127 (command not found) and, again without `-e`, continue. `|| exit 1` turns +# both of those into a closed failure. +# +# require_bash RETURNS non-zero rather than calling `exit`, on purpose: the caller +# owns its exit status, and returning keeps the function exercisable from a +# harness that has to survive the negative case. + +# require_bash [.] +# 0 — this bash is new enough +# 1 — too old (a refusal naming the feature is printed to stderr), or bad args +require_bash() { + local want="${1:-}" feature="${2:-}" self want_major want_minor + self="${BASH_SOURCE[1]:-$0}" + + if [ -z "$want" ] || [ -z "$feature" ]; then + echo "FAIL: require_bash needs [.] and a feature description; got '${want}' '${feature}'." >&2 + return 1 + fi + + want_major="${want%%.*}" + if [ "$want" = "$want_major" ]; then + want_minor=0 + else + want_minor="${want#*.}" + fi + case "${want_major}" in '' | *[!0-9]*) + echo "FAIL: require_bash: '${want}' is not [.]." >&2 + return 1 + ;; + esac + case "${want_minor}" in '' | *[!0-9]*) + echo "FAIL: require_bash: '${want}' is not [.]." >&2 + return 1 + ;; + esac + + if [ "${BASH_VERSINFO[0]}" -gt "$want_major" ] || + { [ "${BASH_VERSINFO[0]}" -eq "$want_major" ] && [ "${BASH_VERSINFO[1]}" -ge "$want_minor" ]; }; then + return 0 + fi + + echo "FAIL: ${self} requires bash >= ${want_major}.${want_minor} (${feature}); this is bash ${BASH_VERSION}." >&2 + echo " Refusing to run rather than aborting mid-way and exiting 0 — a gate that certifies nothing must say so." >&2 + echo " Stock macOS /bin/bash is 3.2: under 'set -u' a populated 'declare -A x=([k]=v)' dies with an" >&2 + echo " unbound-variable error and an exit status of 0, which every caller reads as a PASS (D-154)." >&2 + echo " On macOS: run it under a modern bash (e.g. \`brew install bash\`), which is what 'task check' and CI use." >&2 + return 1 +} diff --git a/hack/release/verify-artifacts.sh b/hack/release/verify-artifacts.sh index 89b75fab..23c01ec6 100755 --- a/hack/release/verify-artifacts.sh +++ b/hack/release/verify-artifacts.sh @@ -6,6 +6,27 @@ # hack/release/verify-artifacts.sh [--dist DIR] [--expected-version VERSION] [--require-signature] set -euo pipefail +# BASH >= 4.0 REQUIRED, ASSERTED FIRST — before anything else can partially run (D-154). +# The checksum walk below declares an associative array. Stock macOS /bin/bash is 3.2, which has +# none. Today's literal is EMPTY, so 3.2 degrades it to `declare: -A: invalid option` and this +# script's `set -e` turns that into exit 2 — it fails closed, but only INCIDENTALLY: populate the +# literal, or drop `-e`, and it becomes the silent exit-0 fail-open D-154 records against +# hack/docs/truthlag_pins_test.sh. The floor is therefore declared, not inferred from how one +# construct happens to degrade. Behaviour change, stated: under 3.2 even `--help` now refuses. +# The lib is resolved script-relative first, then from the enclosing git checkout: this file in +# particular is executed as a mutated COPY out of a mktemp dir by +# hack/release/install_cosign_pin_test.sh §5e-5g, where script-relative resolution cannot work. +# Unresolvable => refuse, never proceed. +_assent_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" 2>/dev/null && pwd || true)" +[ -n "$_assent_lib" ] || _assent_lib="$(cd "$(git rev-parse --show-toplevel 2>/dev/null)/hack/lib" 2>/dev/null && pwd || true)" +[ -r "${_assent_lib}/require-bash.sh" ] || { + echo "FAIL: $0 cannot locate hack/lib/require-bash.sh — refusing to run without its bash version floor (D-154)." >&2 + exit 1 +} +# shellcheck source=hack/lib/require-bash.sh +. "${_assent_lib}/require-bash.sh" || exit 1 +require_bash 4.0 "declare -A (associative arrays)" || exit 1 + DIST="dist" EXPECTED_VERSION="" REQUIRE_SIGNATURE=0 diff --git a/hack/validate-schemas-stock.sh b/hack/validate-schemas-stock.sh index a30803ad..82252f6e 100755 --- a/hack/validate-schemas-stock.sh +++ b/hack/validate-schemas-stock.sh @@ -43,6 +43,24 @@ set -u set -o pipefail +# BASH >= 4.4 REQUIRED, ASSERTED FIRST — before anything else can partially run (D-154). +# `mapfile -d ''` is 4.4; plain `mapfile -t` and the associative `kind_to_schema` need 4.0, so +# 4.4 is the binding floor. That floor was already DOCUMENTED in the header above — and a comment +# is not a guard: under stock macOS /bin/bash 3.2 `mapfile` was "command not found", `all_schemas` +# stayed unset, and every loop below ran against an unbound array. It does exit non-zero, so it +# fails closed, but it fails closed several screens later with a diagnostic that names neither +# bash nor its version. Pinned by hack/lint/bash_version_guard_test.sh. Resolution order matches +# the other two guards: script-relative, then the enclosing git checkout, then refuse. +_assent_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/lib" 2>/dev/null && pwd || true)" +[ -n "$_assent_lib" ] || _assent_lib="$(cd "$(git rev-parse --show-toplevel 2>/dev/null)/hack/lib" 2>/dev/null && pwd || true)" +[ -r "${_assent_lib}/require-bash.sh" ] || { + echo "FAIL: $0 cannot locate hack/lib/require-bash.sh — refusing to run without its bash version floor (D-154)." >&2 + exit 1 +} +# shellcheck source=hack/lib/require-bash.sh +. "${_assent_lib}/require-bash.sh" || exit 1 +require_bash 4.4 "mapfile -d '' (NUL-delimited readarray)" || exit 1 + repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "error: must run inside the git repository" >&2 exit 2 diff --git a/openspec/specs/backlog.md b/openspec/specs/backlog.md index df8d3743..370deb40 100644 --- a/openspec/specs/backlog.md +++ b/openspec/specs/backlog.md @@ -744,6 +744,7 @@ that claim). | COUNT1-F01 | Two `go test` cache blind spots left inside `task test` / `task coverage` (`./...` and `./internal/...`): `internal/schemadrift` compares local `schemas/` against `git show origin/main:…` in a **subprocess**, and `internal/provider/isolation_test.go:29` `go build`s `./testdata/maliciousexec` at runtime | **[autonomous]** | — | found *by* the `-count=1` lane, deliberately left alone there: fixing them means carving packages out of `test`/`coverage` and losing an otherwise-honest whole-tree cache | | CI-TOOLCHAIN | **DONE (D-158)** — `verify` was red on **every** PR, including a six-file Markdown diff with zero Go bytes, because `go-version: stable` rolled Go 1.26 → 1.27.0 while `GOLANGCI_LINT_VERSION` sat at `v2.12.2` (built with go1.26.x, so its `go/types` cannot read the 1.27 stdlib); pin bumped to `v2.13.1`, whose "go1.27 support" commit and `go 1.26.0` go.mod directive make it the first release that can | **[autonomous]** | — | not owned by any in-flight lane — the pin is a CI-only workflow env var, and the break blocked all of them | | CI-TOOLCHAIN-F01 | The linter/toolchain coupling has **no early detector**: dependabot's `github-actions` ecosystem updates `uses:` refs, not `env:` literals, so `GOLANGCI_LINT_VERSION` rots silently until the next Go minor reds every PR at once (~6-monthly). Candidate: a text gate comparing the pinned linter release's go.mod `go 1.N` directive against the toolchain CI resolves, or an updater that watches the literal | **[autonomous]** | D-158 | deliberately out of scope of the fix lane: three lanes were parked behind it, and a new gate belongs with `hack/lint/**`, which was owned by a concurrent lane | +| BASH32-F01 | **DONE** — three `hack/**` gate scripts used bash 4+ features with no version guard; under stock macOS `/bin/bash` 3.2 `hack/docs/truthlag_pins_test.sh` died at its `declare -A`, skipped its final `OK:` banner and **exited 0**, so `task docs-gates` (and `task check`) read a gate that certified almost nothing as green. Fixed by a shared, per-script-parameterised `hack/lib/require-bash.sh` floor (4.0 / 4.0 / 4.4), enforced by a new `hack/lint/bash_version_guard_test.sh` wired as `task check` stage 20 (D-154) | **[autonomous]** | — | found *by* the AUD2 wave's macOS runs, not by any audit: CI is ubuntu/bash 5, so the hole is LOCAL-only and no CI lane could have surfaced it | Sizing, from the S03 implementer's reading of the call site: `verify_cosign()` (`hack/release/verify-artifacts.sh:118–125`) has the byte-identical unpinned From bbe9031d380da71306c4788dded9361e09ddafac Mon Sep 17 00:00:00 2001 From: Konrad Heimel Date: Sun, 23 Aug 2026 18:00:15 +0200 Subject: [PATCH 2/4] :white_check_mark: test(hack): gate that hack/** bash 4+ features declare a floor (BASH32-F01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, the next gate author rediscovers D-154 the way this one was found — by accident, on macOS, after the gate had been silently certifying nothing for months. `hack/lint/bash_version_guard_test.sh` scans every `hack/**/*.sh` for command-position bash 4+ constructs, requires each to declare a floor via `require_bash`, requires that floor to be at least the FEATURE's own minimum (`declare -A` 4.0, `local -n` 4.3, `mapfile -d ''` 4.4), and requires `|| exit 1` on the source and require_bash lines — the callers run `set -u` without `-e`, so a failed source or an undefined function would otherwise continue straight into the guarded construct. Anti-vacuity, which is what this repo's reviews reliably find missing: - all 10 detection patterns must COMPILE. A malformed regexp makes grep exit 2 per file, match nothing and leave the scan green; that is not hypothetical — the first draft used `|` both as a field separator and inside three patterns, truncating them to unbalanced parentheses. - the scan must still SEE all four known feature users, so a typo reds. - eight negative controls, all on REAL copies of the shipped scripts rather than synthetic fixtures: guard block stripped => flagged; pristine copy => clean; floor lowered 4.4 -> 4.0 => flagged; `|| exit 1` dropped => flagged; a COMMENT mentioning BASH_VERSINFO => still flagged, while a real BASH_VERSINFO expression is accepted (the aud2 precedent's branch, which no other control touches). - the crux: under a real bash 3.2, a guard-stripped copy of truthlag_pins_test.sh in a copied tree must exit 0, with the final `OK:` banner ABSENT, after printing at least one PASS line, with `unbound variable` on stderr. "It fails" would have passed against the two scripts that fail closed and proved nothing. - that control needs a bash < 4 and CANNOT run on CI ubuntu. It skips with a ten-line block on stderr and the success line itself names the gap, so a green CI run is never readable as evidence for it. The gate is bash-3.2-clean and asserts that about itself, together with hack/lib/require-bash.sh: guard machinery that cannot run under the shell it protects against cannot refuse cleanly. It also carries an EXIT trap that reds if it ever exits 0 without reaching its last line — the defect it exists to catch, applied to itself. Wired as `task check` stage 20 and pinned in hack/audit/exitgate_test.sh's CHECK_STAGES (19 -> 20) plus STAGE_BODY_PINS in this same commit, which is what that array exists to force. CHANGELOG.md is the regenerated `task changelog-write` output for the preceding commit. Stated limits, in the script's own comments: it sees command-position constructs in `hack/**/*.sh` only — not `eval`, dynamically built command names, bash 4 EXPANSIONS (`${v^^}`, globstar), non-.sh files, anything outside `hack/`, or the numeric floor of a hand-rolled BASH_VERSINFO guard. --- CHANGELOG.md | 2 + Taskfile.yml | 13 + hack/audit/exitgate_test.sh | 6 + hack/lint/bash_version_guard_test.sh | 492 +++++++++++++++++++++++++++ 4 files changed, 513 insertions(+) create mode 100755 hack/lint/bash_version_guard_test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d474d4..19cf6d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,12 +112,14 @@ repository still gets a decision, never by following the link; no release carrie - :lock: fix(release): widen the cosign identity pin to the real signer casing (SEC-03) - :lock: fix(provider): bound exec stdout, set WaitDelay, capture stderr - :lock: fix(release): pin cosign signer identity on the maintainer verify path (AUD2-F01) +- :lock: fix(hack): refuse to run gate scripts under a too-old bash (BASH32-F01) ### Testing - :white_check_mark: test(cmd): assert the REL-03 error wrap as one contiguous substring - :test: test(compare): kill the surviving EffectChallenge intervention mutant (TEST-02) - :white_check_mark: test(audit): pin the AUD2 remediations behind a PR-visible exit gate - :white_check_mark: test(audit): assert verify.yaml still triggers on pull_request +- :white_check_mark: test(hack): gate that hack/** bash 4+ features declare a floor (BASH32-F01) ## [0.3.0] - 2026-08-18 ### Chores diff --git a/Taskfile.yml b/Taskfile.yml index eee0cb0b..a6096e73 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -135,6 +135,14 @@ tasks: # .github/workflows/**, so without this line an unpinned workflow is caught # only by CI — and only for as long as the CI step itself survives. - task: lint-workflow-pins-test + # BASH32-F01 / D-154: every hack/** script that uses a bash 4+ feature must + # declare a version floor. Without it, `hack/docs/truthlag_pins_test.sh` under + # stock macOS /bin/bash 3.2 died at its `declare -A` and EXITED 0 — the + # docs-gates stage above read that as green while almost nothing had been + # checked. Nothing in CI can see this (ubuntu is bash 5), which is why it + # survived; the gate is cheap and offline, and its behavioural control runs + # only where a bash 3.2 exists and says so loudly when it skips. + - task: lint-bash-version-guard-test # EX-S08 (REQ-EX-S08-03): the dogfood-examples gate's own guard. Without # this, deleting `- task: dogfood-examples` from check: above passes # every wired gate — the same manual-gate defect D-124 closed elsewhere. @@ -165,6 +173,11 @@ tasks: cmds: - bash hack/lint/workflow_pins_test.sh + lint-bash-version-guard-test: + desc: "BASH32-F01 gate: every hack/** script using a bash 4+ feature declares a sufficient bash version floor (D-154)" + cmds: + - bash hack/lint/bash_version_guard_test.sh + lint-depguard-test: desc: "AUD-S07 gate: adversarial proof the D-123 depguard boundary rules fire (REQ-AUD-S07-01)" cmds: diff --git a/hack/audit/exitgate_test.sh b/hack/audit/exitgate_test.sh index 0e7c0ae1..31680055 100644 --- a/hack/audit/exitgate_test.sh +++ b/hack/audit/exitgate_test.sh @@ -143,6 +143,11 @@ CHECK_STAGES=( docs-gates lint-depguard-test lint-workflow-pins-test + # BASH32-F01 (D-154): the bash-version-floor gate. Added in the same commit as + # its `check:` line — hack/docs/truthlag_pins_test.sh exited 0 under stock macOS + # bash 3.2 after dying at its `declare -A`, so `task check` was locally green + # while certifying almost nothing, and no CI lane can see that (ubuntu is bash 5). + lint-bash-version-guard-test dogfood-wiring-test ci-audit-test # AUD2-S05 (REQ-AUD2-S05-03): the P5-AUD2 exit gate — the four 2026-08-18 @@ -181,6 +186,7 @@ STAGE_BODY_PINS=( 'coverage|min="{{.COVERAGE_MIN}}"|an unrendered or dropped threshold makes the awk compare against 0 and admit anything (D-128)' 'lint-workflow-pins-test|bash hack/lint/workflow_pins_test.sh|a wired stage with a gutted body is the same defect one level down' 'ci-audit-test|bash hack/release/ci_audit_test.sh|a wired stage with a gutted body is the same defect one level down' + 'lint-bash-version-guard-test|bash hack/lint/bash_version_guard_test.sh|a wired stage with a gutted body is the same defect one level down (BASH32-F01)' ) # (6) Immutable base ref for the schema freeze. Overridable only to move it diff --git a/hack/lint/bash_version_guard_test.sh b/hack/lint/bash_version_guard_test.sh new file mode 100755 index 00000000..336defee --- /dev/null +++ b/hack/lint/bash_version_guard_test.sh @@ -0,0 +1,492 @@ +#!/usr/bin/env bash +# bash_version_guard_test.sh — BASH32-F01 / D-154. +# +# A hack/** gate script that uses a bash 4+ feature and does NOT declare a version +# floor is a gate that can certify nothing while reporting success. Measured, on +# stock macOS /bin/bash 3.2.57: `hack/docs/truthlag_pins_test.sh` printed 20 PASS +# lines, died at its `declare -A` (3.2 has no associative arrays, so under `set -u` +# it evaluates the subscript arithmetically and hits "unbound variable"), never +# printed its final `OK:` banner — and EXITED 0. `task docs-gates`, and therefore +# `task check`, read that as green. CI is ubuntu/bash 5, so nothing merges through +# this path; it is a LOCAL trust hole, which is exactly why it survived. +# +# WHAT THIS GATE ASSERTS +# (1) every hack/**/*.sh using a command-position bash 4+ construct declares a +# floor, that floor is at least the feature's own minimum, and the call +# carries `|| exit 1` (the callers run `set -u` WITHOUT `-e`, so a failed +# source or a missing function would otherwise just carry on); +# (2) the scan still SEES the known population — a typo in a detection pattern +# must red, not silently match nothing and pass; +# (3) negative control: real copies of the guarded scripts, guard lines stripped, +# ARE flagged; a floor lowered below its feature's minimum IS flagged; a +# dropped `|| exit 1` IS flagged; +# (4) the D-154 fail-open control: under a real bash 3.2, guard-stripped +# `truthlag_pins_test.sh` exits 0 with its banner ABSENT after printing PASS +# lines, and the guarded original exits NON-zero. Skips LOUDLY where no bash +# 3.2 exists, which on CI is every run. +# +# WHAT IT CAN AND CANNOT SEE — stated plainly, because an honest narrow gate is +# worth more than an overreaching one: +# +# CAN SEE: `declare -A` / `typeset -A` / `local -A`, `declare -n` / `local -n`, +# `mapfile` (and `mapfile -d`, which is 4.4 rather than 4.0), +# `readarray`, `wait -n` and `coproc` — each ONLY when it is the first +# word on its line (optional leading whitespace). Every real occurrence +# in this repo has that shape, and anchoring at a command position is +# what lets this file carry the patterns without matching itself. +# +# CANNOT SEE: the same constructs after `&&`, `||`, `;`, `then`, in an `if`/ +# `while` head, behind `eval`, or assembled from a variable; bash 4 +# EXPANSIONS — `${v^^}`, `${v,,}`, `${!prefix@}`, `**` globstar, +# `&>>` — because detecting those needs unanchored patterns that this +# file could not carry without flagging itself; non-`.sh` files; +# anything outside `hack/`; and the numeric floor of a hand-rolled +# `BASH_VERSINFO` guard (such a guard is accepted as PRESENT, but its +# number is not read, so `hack/audit/aud2_exitgate_test.sh` is graded +# on having a guard, not on its value). +# +# THIS FILE IS BASH-3.2-CLEAN ON PURPOSE and asserts that about itself below: a +# gate about bash floors must still be able to run, and report, on the very shell +# whose behaviour it describes. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +WORK="$(mktemp -d)" +FINISHED=0 + +# The defect this gate exists to catch, applied to the gate itself: an early death +# under `set -u` leaves the exit status at 0. Nothing may read this script as green +# unless it reached its last line. +on_exit() { + rc=$? + rm -rf "$WORK" + if [ "$FINISHED" -eq 0 ] && [ "$rc" -eq 0 ]; then + echo "FAIL: bash_version_guard_test.sh terminated before its final line yet exited 0 — refusing to report success" >&2 + exit 1 + fi + exit "$rc" +} +trap on_exit EXIT + +fails=0 +skips=0 +pass() { echo "PASS $1"; } +fail() { + echo "FAIL $1" >&2 + fails=$((fails + 1)) +} + +# --- feature table ----------------------------------------------------------- +# `;;`. The regexp is matched +# with `^[[:space:]]*` prepended, i.e. at a command position only. The minimum is +# per FEATURE, never per repo: a blanket floor would refuse shells that can run +# the 4.0 gates and would hide which construct actually binds. +# +# The field separator is `;`, NOT `|`: several of these regexps need alternation, +# and splitting on `|` silently truncated them to unbalanced parentheses that +# grep rejected — a scan that errors per file and matches nothing, which is the +# vacuity this gate is supposed to be immune to. `;` cannot appear in a pattern +# here; assertion (2) below is what makes a repeat of that mistake red. +FEATURES=( + 'declare[[:space:]]+-A;4.0;declare -A (associative array)' + 'typeset[[:space:]]+-A;4.0;typeset -A (associative array)' + 'local[[:space:]]+-A;4.0;local -A (associative array)' + 'declare[[:space:]]+-n;4.3;declare -n (nameref)' + 'local[[:space:]]+-n;4.3;local -n (nameref)' + 'mapfile[[:space:]]+-[a-zA-Z]*d;4.4;mapfile -d (alternate delimiter)' + 'mapfile([[:space:]]|$);4.0;mapfile' + 'readarray([[:space:]]|$);4.0;readarray' + 'wait[[:space:]]+-n;4.3;wait -n' + 'coproc([[:space:]]|$);4.0;coproc' +) + +# --- the population this gate must keep seeing (anti-vacuity, assertion 2) ---- +# If a detection pattern is mistyped the scan finds nothing and every assertion +# below passes. These four files are the entire measured population as of D-154; +# the gate reds if any of them stops being detected, so the patterns cannot rot +# silently. Removing a file from this list is a deliberate act, like CHECK_STAGES. +KNOWN_FEATURE_USERS=( + hack/docs/truthlag_pins_test.sh + hack/release/verify-artifacts.sh + hack/validate-schemas-stock.sh + hack/audit/aud2_exitgate_test.sh +) + +# Files that must NOT be detected: the guard machinery itself has to run under the +# shell it protects against, so a bash 4 construct appearing in either of these is +# the defect reintroduced inside the fix. +MUST_BE_CLEAN=( + hack/lib/require-bash.sh + hack/lint/bash_version_guard_test.sh +) + +# --- version arithmetic (3.2-safe) ------------------------------------------- +ver_ge() { # — true when a >= b + local a="$1" b="$2" amaj amin bmaj bmin + amaj="${a%%.*}" + if [ "$a" = "$amaj" ]; then amin=0; else amin="${a#*.}"; fi + bmaj="${b%%.*}" + if [ "$b" = "$bmaj" ]; then bmin=0; else bmin="${b#*.}"; fi + if [ "$amaj" -gt "$bmaj" ]; then return 0; fi + if [ "$amaj" -lt "$bmaj" ]; then return 1; fi + [ "$amin" -ge "$bmin" ] +} + +# --- the scan ---------------------------------------------------------------- +# scan_features — one line per feature-using script: +# || +scan_features() { # + local root="$1" f rel spec pat min desc need feats + while IFS= read -r f; do + rel="${f#"$root"/}" + need="" + feats="" + for spec in "${FEATURES[@]}"; do + pat="${spec%%;*}" + min="${spec#*;}" + min="${min%%;*}" + desc="${spec##*;}" + if grep -Eq "^[[:space:]]*${pat}" "$f"; then + if [ -z "$feats" ]; then feats="$desc"; else feats="${feats}, ${desc}"; fi + if [ -z "$need" ] || ! ver_ge "$need" "$min"; then need="$min"; fi + fi + done + if [ -n "$need" ]; then + printf '%s|%s|%s\n' "$rel" "$need" "$feats" + fi + done < <(find "$root/hack" -type f -name '*.sh' | sort) + return 0 +} + +# declared_floor — highest version passed to require_bash, empty if none. +declared_floor() { + local f="$1" v best="" + while IFS= read -r v; do + [ -n "$v" ] || continue + if [ -z "$best" ] || ! ver_ge "$best" "$v"; then best="$v"; fi + done < <(grep -Eo '^[[:space:]]*require_bash[[:space:]]+[0-9]+(\.[0-9]+)?' "$f" 2>/dev/null | + grep -Eo '[0-9]+(\.[0-9]+)?$') + printf '%s' "$best" +} + +# A hand-rolled guard (the hack/audit/aud2_exitgate_test.sh:71 precedent) counts as +# present. It must be CODE: `# TODO: add a BASH_VERSINFO check` is a comment, and +# accepting it would make this the one branch of grade_root that cannot fail. +# Control (e) below is what keeps that honest. +has_versinfo_guard() { grep -Eq '^[^#]*BASH_VERSINFO' "$1" 2>/dev/null; } + +# Lines of the guard idiom that are missing their `|| exit 1`. The callers run +# `set -u` with no `-e`: without it a failed source returns non-zero and execution +# continues into the guarded construct, and an undefined require_bash returns 127 +# and does the same. That is the original fail-open, one line up. +unterminated_guard_lines() { # + grep -E '^[[:space:]]*(require_bash[[:space:]]|\.[[:space:]].*require-bash\.sh)' "$1" | + grep -Ev '\|\|[[:space:]]*exit[[:space:]]+1[[:space:]]*$' || true +} + +# grade_root