From db37d668090b302a849b9eb035b51e3436aeae49 Mon Sep 17 00:00:00 2001 From: Pedro Algarvio Date: Sat, 19 Sep 2026 22:14:22 +0100 Subject: [PATCH 1/2] test: centralize GNU-vs-BSD stat compat; close a real-write env leak in codexpromptroutecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve gates independently hand-rolled the same detect-GNU-vs-BSD-stat fix (a `stat -f ... || stat -c ...` one-liner never reaches its `-c` fallback on Linux, because GNU's `-f` is a different, valid flag — filesystem stat, not BSD's format string — so it succeeds with junk instead of failing). test/lib/statcompat.sh centralizes the flavour detection and provides ready-to-call functions (mtime_of/inode_of/ mode_of/size_of/inode_mtime_of) so sourcing it replaces each file's own copy-pasted block; two gates (evictioncheck.sh, qsnapprefetchcheck.sh) keep a one-line local wrapper for their own fallback-string convention. mcpeditmodecheck.sh's separately-named `file_mode` was the same function under a different name; renamed its call sites to `mode_of` instead of keeping a second name for one thing. codexpromptroutecheck.sh's `skills install --codex --hook` calls only override HOME/CODEX_HOME/AGENTS_HOME, not RIPWIRE_DATA_HOME — a real write into an ambient store path once the embedded-skills feature (redhat-et/ripwire#225) lands, same class of leak as the CLAUDE_CONFIG_DIR incident test/lib/unset-agent-env-variables.sh documents on that branch (not yet on main). test/lib/clean-env.sh is this PR's main-side equivalent, scoped to the vars gates on main actually read. Verified: full pargates suite, alone and under added CPU contention; --quality-delta gating=0; determinism + xmllint clean. The three gates that fail under -j6 parallel load (legendcoveragecheck.sh, pargatescheck.sh, rootrelemitcheck.sh) all pass alone and are unrelated to any file this commit touches — confirmed pre-existing/ environmental, not introduced here. Co-Authored-By: Claude Sonnet 5 --- test/cachehashcheck.sh | 13 +------------ test/cachesplitcheck.sh | 11 +---------- test/clonecachecheck.sh | 16 +--------------- test/codexpromptroutecheck.sh | 5 +++-- test/evictioncheck.sh | 12 ++---------- test/g1freshcheck.sh | 14 +------------- test/headsnapcachecheck.sh | 11 +---------- test/lib/clean-env.sh | 6 ++++++ test/lib/statcompat.sh | 20 ++++++++++++++++++++ test/mcpeditmodecheck.sh | 17 +++-------------- test/portablecachecheck.sh | 16 +--------------- test/prcontextcheck.sh | 13 +------------ test/qsnapcachecheck.sh | 11 +---------- test/qsnapprefetchcheck.sh | 12 ++---------- test/statgatecheck.sh | 12 ------------ 15 files changed, 44 insertions(+), 145 deletions(-) create mode 100644 test/lib/clean-env.sh create mode 100644 test/lib/statcompat.sh diff --git a/test/cachehashcheck.sh b/test/cachehashcheck.sh index a3d3d8222..ffdeba791 100755 --- a/test/cachehashcheck.sh +++ b/test/cachehashcheck.sh @@ -42,6 +42,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" # allow a repo-relative RIPWIRE_BIN TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT @@ -94,18 +95,6 @@ touch -r "$TMP/f.cpp.ref" "$WORK/f.cpp" touch -r "$DIR_REF" "$WORK" 2>/dev/null || true # Confirm the mtime restore actually worked (sanity on the attack itself, not the tool under test). -# L3 (Linux probe): portable stat reader(s). GNU coreutils and BSD/macOS disagree on both the flag and the -# format directives, and the `stat -f FMT ... || stat -c FMT ...` fallback this gate used is a TRAP. On GNU, -# `-f` means FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on -# coreutils 9.11, `stat -f %i FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1. The -# `||` arm then appends the right number under six lines of junk -- so a string compare fails, a numeric -# compare dies with "integer expression expected", and a `|| echo MISSING` variant reports MISSING forever -# (a gate that then passes by comparing nothing to nothing). Detect the flavour ONCE, use one form. -if stat --version >/dev/null 2>&1; then # GNU coreutils - mtime_of(){ stat -c '%Y' "$1" 2>/dev/null; } -else # BSD / macOS - mtime_of(){ stat -f '%m' "$1" 2>/dev/null; } -fi ref_mtime="$( mtime_of "$TMP/f.cpp.ref" )" new_mtime="$( mtime_of "$WORK/f.cpp" )" if [ "$ref_mtime" = "$new_mtime" ]; then diff --git a/test/cachesplitcheck.sh b/test/cachesplitcheck.sh index 622d804d0..21b99e61b 100755 --- a/test/cachesplitcheck.sh +++ b/test/cachesplitcheck.sh @@ -29,6 +29,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" # allow a repo-relative RIPWIRE_BIN TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT @@ -46,16 +47,6 @@ CORPUS="$ROOT/test/fixture" XDG="$TMP/xdg"; mkdir -p "$XDG" CACHEDIR="$XDG/ripwire" -# L3 (Linux probe): portable stat reader(s). GNU coreutils and BSD/macOS disagree on both the flag and the -# format directives, and the `stat -f FMT ... || stat -c FMT ...` fallback this gate used is a TRAP. On GNU, -# `-f` means FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on -# coreutils 9.11, `stat -f %i FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1. The -# `||` arm then appends the right number under six lines of junk -- so a string compare fails, a numeric -# compare dies with "integer expression expected", and a `|| echo MISSING` variant reports MISSING forever -# (a gate that then passes by comparing nothing to nothing). Detect the flavour ONCE, use one form. -if stat --version >/dev/null 2>&1; then inode_of(){ stat -c %i "$1" 2>/dev/null; } # GNU coreutils -else inode_of(){ stat -f %i "$1" 2>/dev/null; } # BSD / macOS -fi # glob helper: echo the single matching class file (or empty). Y4: shard-aware lookup — a blob may # live flat under $CACHEDIR or under $CACHEDIR// (2-hex-char shard), so search both via find -maxdepth 2. richfile(){ find "$CACHEDIR" -maxdepth 2 -type f -name 'ripwire-*-rich.bin' 2>/dev/null | head -1; } diff --git a/test/clonecachecheck.sh b/test/clonecachecheck.sh index 95bec3299..00ece6b04 100755 --- a/test/clonecachecheck.sh +++ b/test/clonecachecheck.sh @@ -29,6 +29,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT @@ -66,21 +67,6 @@ FIVE_DAYS_AGO=$(( $(date +%s) - 5*86400 )) touch -t "$(date -r "$FIVE_DAYS_AGO" +%Y%m%d%H%M.%S 2>/dev/null || date -d "@$FIVE_DAYS_AGO" +%Y%m%d%H%M.%S)" "$CACHEDIR" 2>/dev/null \ || touch -d "@$FIVE_DAYS_AGO" "$CACHEDIR" 2>/dev/null -# L3 (Linux probe): portable stat reader(s). GNU coreutils and BSD/macOS disagree on both the flag and the -# format directives, and the `stat -f FMT ... || stat -c FMT ...` fallback this gate used is a TRAP. On GNU, -# `-f` means FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on -# coreutils 9.11, `stat -f %i FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1. The -# `||` arm then appends the right number under six lines of junk -- so a string compare fails, a numeric -# compare dies with "integer expression expected", and a `|| echo MISSING` variant reports MISSING forever -# (a gate that then passes by comparing nothing to nothing). Detect the flavour ONCE, use one form. -if stat --version >/dev/null 2>&1; then # GNU coreutils - mtime_of(){ stat -c '%Y' "$1" 2>/dev/null; } - mode_of(){ stat -c '%a' "$1" 2>/dev/null; } -else # BSD / macOS - mtime_of(){ stat -f '%m' "$1" 2>/dev/null; } - mode_of(){ stat -f '%Lp' "$1" 2>/dev/null; } -fi - BEFORE_MTIME="$( mtime_of "$CACHEDIR" )" env -u TMPDIR XDG_CACHE_HOME="$XDG" "$BIN" "$URL" >"$TMP/a_stdout" 2>"$TMP/a_stderr" diff --git a/test/codexpromptroutecheck.sh b/test/codexpromptroutecheck.sh index 7ee619b13..c6f0ec124 100755 --- a/test/codexpromptroutecheck.sh +++ b/test/codexpromptroutecheck.sh @@ -4,6 +4,7 @@ # and length, never prompt text, and hook installation stays idempotent. set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/clean-env.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" HOOK="$ROOT/hooks/ripwire-codex-route.sh" @@ -137,9 +138,9 @@ OFF="$( printf '%s\n' "{\"prompt\":\"$PROMPT\",\"cwd\":\"$TMP/repo\",\"session_i && ok "routing-meter opt-out keeps advice but writes no log or pending state" \ || no "routing-meter opt-out suppressed advice or wrote state" -HOME="$TMP/home" CODEX_HOME="$TMP/home/.codex" AGENTS_HOME="$TMP/home/.agents" \ +HOME="$TMP/home" CODEX_HOME="$TMP/home/.codex" AGENTS_HOME="$TMP/home/.agents" RIPWIRE_DATA_HOME="$TMP/home/.local/share/ripwire" \ bash "$ROOT/skills/install.sh" --codex --hook >/dev/null -HOME="$TMP/home" CODEX_HOME="$TMP/home/.codex" AGENTS_HOME="$TMP/home/.agents" \ +HOME="$TMP/home" CODEX_HOME="$TMP/home/.codex" AGENTS_HOME="$TMP/home/.agents" RIPWIRE_DATA_HOME="$TMP/home/.local/share/ripwire" \ bash "$ROOT/skills/install.sh" --codex --hook >/dev/null SETTINGS="$TMP/home/.codex/hooks.json" jq -e --arg cmd "$HOOK" '[.hooks.UserPromptSubmit[]?.hooks[]? | select(.command == $cmd)] | length == 1' "$SETTINGS" >/dev/null 2>&1 \ diff --git a/test/evictioncheck.sh b/test/evictioncheck.sh index cfa20964e..45afa8b4d 100755 --- a/test/evictioncheck.sh +++ b/test/evictioncheck.sh @@ -48,6 +48,7 @@ # Usage: test/evictioncheck.sh | RIPWIRE_BIN=build_r2a1/ripwire test/evictioncheck.sh set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" fail=0 @@ -63,16 +64,7 @@ REPO="$TMP/repo"; mkdir -p "$REPO" # apparent (logical) byte size of a file — what fs::file_size measures, NOT `du`'s block-usage view # (a sparse file's disk usage is ~0 but its apparent size is what the sweep's byte budget compares against). -# L3 (Linux probe): portable stat reader(s). GNU coreutils and BSD/macOS disagree on both the flag and the -# format directives, and the `stat -f FMT ... || stat -c FMT ...` fallback this gate used is a TRAP. On GNU, -# `-f` means FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on -# coreutils 9.11, `stat -f %i FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1. The -# `||` arm then appends the right number under six lines of junk -- so a string compare fails, a numeric -# compare dies with "integer expression expected", and a `|| echo MISSING` variant reports MISSING forever -# (a gate that then passes by comparing nothing to nothing). Detect the flavour ONCE, use one form. -if stat --version >/dev/null 2>&1; then apparentsize(){ stat -c %s "$1" 2>/dev/null || echo 0; } # GNU coreutils -else apparentsize(){ stat -f %z "$1" 2>/dev/null || echo 0; } # BSD / macOS -fi +apparentsize(){ size_of "$1" || echo 0; } # Y4: shard-aware — every blob glob below now looks at both the flat top-level AND any 2-hex-char shard # subdir (mindepth/maxdepth bound it to exactly the layouts the sweep itself understands; never an # open-ended walk of a shared $TMPDIR). diff --git a/test/g1freshcheck.sh b/test/g1freshcheck.sh index 2e8079966..d1f4fbdcc 100755 --- a/test/g1freshcheck.sh +++ b/test/g1freshcheck.sh @@ -18,6 +18,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" ASAN_BIN="$ROOT/asan/ripwire" ASAN_DIR="$ROOT/asan" SRC_DIR="$ROOT/src" @@ -46,19 +47,6 @@ if [ ! -f "$ASAN_BIN" ]; then fi # Both asan/ripwire and src/ exist. Check if the binary is older than the newest src file. -# L3 (Linux probe): portable stat reader(s). GNU coreutils and BSD/macOS disagree on both the flag and the -# format directives, and the `stat -f FMT ... || stat -c FMT ...` fallback this gate used is a TRAP. On GNU, -# `-f` means FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on -# coreutils 9.11, `stat -f %i FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1. The -# `||` arm then appends the right number under six lines of junk -- so a string compare fails, a numeric -# compare dies with "integer expression expected", and a `|| echo MISSING` variant reports MISSING forever -# (a gate that then passes by comparing nothing to nothing). Detect the flavour ONCE, use one form. -if stat --version >/dev/null 2>&1; then # GNU coreutils - mtime_of(){ stat -c '%Y' "$1" 2>/dev/null; } -else # BSD / macOS - mtime_of(){ stat -f '%m' "$1" 2>/dev/null; } -fi - asan_mtime="$( mtime_of "$ASAN_BIN" )" || { no "could not stat asan/ripwire" exit 1 diff --git a/test/headsnapcachecheck.sh b/test/headsnapcachecheck.sh index 5c432c051..d001daad8 100755 --- a/test/headsnapcachecheck.sh +++ b/test/headsnapcachecheck.sh @@ -28,6 +28,7 @@ # Usage: test/headsnapcachecheck.sh | RIPWIRE_BIN=build_w2e/ripwire test/headsnapcachecheck.sh set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" fail=0 @@ -41,16 +42,6 @@ REPO="$( mktemp -d )"; TMP="$( mktemp -d )"; trap 'rm -rf "$REPO" "$TMP"' EXIT XDG="$TMP/xdg"; mkdir -p "$XDG" CACHEDIR="$XDG/ripwire" -# L3 (Linux probe): portable stat reader(s). GNU coreutils and BSD/macOS disagree on both the flag and the -# format directives, and the `stat -f FMT ... || stat -c FMT ...` fallback this gate used is a TRAP. On GNU, -# `-f` means FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on -# coreutils 9.11, `stat -f %i FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1. The -# `||` arm then appends the right number under six lines of junk -- so a string compare fails, a numeric -# compare dies with "integer expression expected", and a `|| echo MISSING` variant reports MISSING forever -# (a gate that then passes by comparing nothing to nothing). Detect the flavour ONCE, use one form. -if stat --version >/dev/null 2>&1; then inode_of(){ stat -c %i "$1" 2>/dev/null; } # GNU coreutils -else inode_of(){ stat -f %i "$1" 2>/dev/null; } # BSD / macOS -fi # Y4: shard-aware lookup — a blob may be flat under $CACHEDIR or under $CACHEDIR// (2-hex shard). snapfiles(){ find "$CACHEDIR" -maxdepth 2 -type f -name 'ripwire-qheadsnap-*.bin' 2>/dev/null; } nsnap(){ snapfiles | wc -l | tr -d ' '; } diff --git a/test/lib/clean-env.sh b/test/lib/clean-env.sh new file mode 100644 index 000000000..a5e14e354 --- /dev/null +++ b/test/lib/clean-env.sh @@ -0,0 +1,6 @@ +# clean-env.sh — the agent-home-relocating env vars a gate must clear before it varies HOME= per +# invocation: CODEX_HOME/AGENTS_HOME/HERMES_HOME/CLAUDE_CONFIG_DIR/RIPWIRE_DATA_HOME override the +# default derived from HOME, so a gate that only sets HOME= is not sandboxed on a machine where any of +# these is already exported ambiently. SOURCED, not run. Source this FIRST, then set whatever homes +# the gate actually needs. +unset CODEX_HOME AGENTS_HOME HERMES_HOME CLAUDE_CONFIG_DIR RIPWIRE_DATA_HOME diff --git a/test/lib/statcompat.sh b/test/lib/statcompat.sh new file mode 100644 index 000000000..0f782b2ed --- /dev/null +++ b/test/lib/statcompat.sh @@ -0,0 +1,20 @@ +# statcompat.sh — GNU-vs-BSD `stat` compat, in one place. SOURCED, not run. +# +# GNU coreutils' `-f` is a different, valid flag (filesystem stat, not BSD's format string): it +# succeeds with junk instead of failing, so a caller-local `stat -f ... || stat -c ...` one-liner never +# reaches its own fallback on Linux. Twelve gates hand-rolled that same detect-once-and-redefine fix +# independently before this file existed. Sourcing this gives every one of them a ready-to-call +# function; no caller branches on the flavour itself. +if stat --version >/dev/null 2>&1; then # GNU coreutils + mtime_of() { stat -c '%Y' "$1" 2>/dev/null; } + inode_of() { stat -c '%i' "$1" 2>/dev/null; } + mode_of() { stat -c '%a' "$1" 2>/dev/null; } + size_of() { stat -c '%s' "$1" 2>/dev/null; } + inode_mtime_of() { stat -c '%i %Y' "$1" 2>/dev/null; } +else # BSD / macOS + mtime_of() { stat -f '%m' "$1" 2>/dev/null; } + inode_of() { stat -f '%i' "$1" 2>/dev/null; } + mode_of() { stat -f '%Lp' "$1" 2>/dev/null; } + size_of() { stat -f '%z' "$1" 2>/dev/null; } + inode_mtime_of() { stat -f '%i %m' "$1" 2>/dev/null; } +fi diff --git a/test/mcpeditmodecheck.sh b/test/mcpeditmodecheck.sh index a6d1f99d2..4d3c0d1d7 100755 --- a/test/mcpeditmodecheck.sh +++ b/test/mcpeditmodecheck.sh @@ -18,6 +18,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT @@ -30,18 +31,6 @@ no(){ printf ' FAIL %s\n' "$*"; fail=1; } echo "mcpeditmodecheck: BIN=$BIN" -# portable "octal mode of a file" (BSD/macOS stat -f vs GNU stat -c). -# L3 (Linux probe): the `stat -f FMT ... || stat -c FMT ...` fallback this used is a TRAP. On GNU, `-f` means -# FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on coreutils -# 9.11, `stat -f '%Lp' FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1, so the `||` -# arm appends the right mode under six lines of junk and `[ "$PERM" = "700" ]` can never hold. Detect the -# flavour ONCE, use one form. -file_mode(){ - if stat --version >/dev/null 2>&1; then stat -c '%a' "$1" 2>/dev/null # GNU coreutils - else stat -f '%Lp' "$1" 2>/dev/null # BSD / macOS - fi -} - # drive one replace_symbol_body call through the MCP server (spec-conforming params.arguments form), # echo the tools/call response line. mcp_replace(){ # $1=dir $2=symbol $3=new_body @@ -64,11 +53,11 @@ int run_tool( int x ) } CPP chmod 0755 "$W1/exec.cpp" -BEFORE_MODE="$( file_mode "$W1/exec.cpp" )" +BEFORE_MODE="$( mode_of "$W1/exec.cpp" )" [ "$BEFORE_MODE" = "755" ] || { echo " (setup) could not set fixture mode to 0755 (got $BEFORE_MODE)"; } R1="$( mcp_replace "$W1" run_tool 'int run_tool( int x )\n{\n return x + 2;\n}' )" -AFTER_MODE="$( file_mode "$W1/exec.cpp" )" +AFTER_MODE="$( mode_of "$W1/exec.cpp" )" case "$R1" in *applied*) : ;; diff --git a/test/portablecachecheck.sh b/test/portablecachecheck.sh index d1270726f..d6054416e 100755 --- a/test/portablecachecheck.sh +++ b/test/portablecachecheck.sh @@ -37,6 +37,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" # allow a repo-relative RIPWIRE_BIN TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT @@ -193,21 +194,6 @@ fi # ════════════════════════════════════════════════════════════════════════════════════════════════════ DIRTY_PROBE="$TMP/dirty_probe.cache" "$BIN" "$PATH_A" --cache="$DIRTY_PROBE" --no-stable >/dev/null 2>&1 # cold populate -# L3 (Linux probe): portable stat reader(s). GNU coreutils and BSD/macOS disagree on both the flag and the -# format directives, and the `stat -f FMT ... || stat -c FMT ...` fallback this gate used is a TRAP. On GNU, -# `-f` means FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on -# coreutils 9.11, `stat -f %i FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1. The -# `||` arm then appends the right number under six lines of junk -- so a string compare fails, a numeric -# compare dies with "integer expression expected", and a `|| echo MISSING` variant reports MISSING forever -# (a gate that then passes by comparing nothing to nothing). Detect the flavour ONCE, use one form. -if stat --version >/dev/null 2>&1; then # GNU coreutils - mtime_of(){ stat -c '%Y' "$1" 2>/dev/null; } - size_of(){ stat -c '%s' "$1" 2>/dev/null; } -else # BSD / macOS - mtime_of(){ stat -f '%m' "$1" 2>/dev/null; } - size_of(){ stat -f '%z' "$1" 2>/dev/null; } -fi - BEFORE_SIZE="$( size_of "$DIRTY_PROBE" )" # touch nothing; re-run warm — the cache file must NOT be rewritten (Win-2 dirty-flag: unchanged tree # skips saveCache entirely), which only happens if every file HASH-MATCHED against a re-absolutized key. diff --git a/test/prcontextcheck.sh b/test/prcontextcheck.sh index afcb4e8af..6938d9528 100755 --- a/test/prcontextcheck.sh +++ b/test/prcontextcheck.sh @@ -19,6 +19,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT @@ -119,18 +120,6 @@ echo "$OUT_REF" | grep -q 'base="HEAD"[^>]*files="1"' \ # ── A3-F10: a pure mode flip (chmod, content untouched) must NOT count as a changed file, and the # skipped count must be reported so the information isn't silently lost. Flip src/user.cpp to 755 # (content already committed, unmodified) alongside the real core.cpp content edit above. -# L3 (Linux probe): portable stat reader(s). GNU coreutils and BSD/macOS disagree on both the flag and the -# format directives, and the `stat -f FMT ... || stat -c FMT ...` fallback this gate used is a TRAP. On GNU, -# `-f` means FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on -# coreutils 9.11, `stat -f %i FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1. The -# `||` arm then appends the right number under six lines of junk -- so a string compare fails, a numeric -# compare dies with "integer expression expected", and a `|| echo MISSING` variant reports MISSING forever -# (a gate that then passes by comparing nothing to nothing). Detect the flavour ONCE, use one form. -if stat --version >/dev/null 2>&1; then # GNU coreutils - mode_of(){ stat -c '%a' "$1" 2>/dev/null; } -else # BSD / macOS - mode_of(){ stat -f '%Lp' "$1" 2>/dev/null; } -fi ORIG_MODE="$( mode_of "$REPO/src/user.cpp" )" chmod 755 "$REPO/src/user.cpp" MODEOUT="$( "$BIN" "$REPO" --pr-context --no-cache 2>/dev/null )" diff --git a/test/qsnapcachecheck.sh b/test/qsnapcachecheck.sh index 09fdb6d41..0336832ff 100755 --- a/test/qsnapcachecheck.sh +++ b/test/qsnapcachecheck.sh @@ -28,6 +28,7 @@ # Usage: test/qsnapcachecheck.sh | RIPWIRE_BIN=build_r2a1/ripwire test/qsnapcachecheck.sh set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" fail=0 @@ -41,16 +42,6 @@ REPO="$( mktemp -d )"; TMP="$( mktemp -d )"; trap 'rm -rf "$REPO" "$TMP"' EXIT XDG="$TMP/xdg"; mkdir -p "$XDG" CACHEDIR="$XDG/ripwire" -# L3 (Linux probe): portable stat reader(s). GNU coreutils and BSD/macOS disagree on both the flag and the -# format directives, and the `stat -f FMT ... || stat -c FMT ...` fallback this gate used is a TRAP. On GNU, -# `-f` means FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on -# coreutils 9.11, `stat -f %i FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1. The -# `||` arm then appends the right number under six lines of junk -- so a string compare fails, a numeric -# compare dies with "integer expression expected", and a `|| echo MISSING` variant reports MISSING forever -# (a gate that then passes by comparing nothing to nothing). Detect the flavour ONCE, use one form. -if stat --version >/dev/null 2>&1; then inode_of(){ stat -c %i "$1" 2>/dev/null; } # GNU coreutils -else inode_of(){ stat -f %i "$1" 2>/dev/null; } # BSD / macOS -fi # Y4: shard-aware lookup — a blob may be flat under $CACHEDIR or under $CACHEDIR// (2-hex shard). qsnapfiles(){ find "$CACHEDIR" -maxdepth 2 -type f -name 'ripwire-qsnap-*.bin' 2>/dev/null; } nqsnap(){ qsnapfiles | wc -l | tr -d ' '; } diff --git a/test/qsnapprefetchcheck.sh b/test/qsnapprefetchcheck.sh index 2fa17ebe2..0f1fd1b26 100755 --- a/test/qsnapprefetchcheck.sh +++ b/test/qsnapprefetchcheck.sh @@ -46,6 +46,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" FIX="$ROOT/test/fixture" @@ -110,16 +111,7 @@ wait_for_id() { local i; for i in $( seq 1 200 ); do grep -q "\"id\":$2" "$1" 2> blob_paths() { find "$1" -maxdepth 3 -type f -name "$2" 2>/dev/null; } blob_first() { blob_paths "$1" "$2" | head -1; } qsnap_count() { blob_paths "$1" 'ripwire-qsnap-*.bin' | grep -c . ; } -# L3 (Linux probe): portable stat reader(s). GNU coreutils and BSD/macOS disagree on both the flag and the -# format directives, and the `stat -f FMT ... || stat -c FMT ...` fallback this gate used is a TRAP. On GNU, -# `-f` means FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on -# coreutils 9.11, `stat -f %i FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1. The -# `||` arm then appends the right number under six lines of junk -- so a string compare fails, a numeric -# compare dies with "integer expression expected", and a `|| echo MISSING` variant reports MISSING forever -# (a gate that then passes by comparing nothing to nothing). Detect the flavour ONCE, use one form. -if stat --version >/dev/null 2>&1; then inode_mtime(){ stat -c '%i %Y' "$1" 2>/dev/null || echo "MISSING"; } # GNU coreutils -else inode_mtime(){ stat -f '%i %m' "$1" 2>/dev/null || echo "MISSING"; } # BSD / macOS -fi +inode_mtime(){ inode_mtime_of "$1" || echo "MISSING"; } skip(){ printf ' SKIP %s\n' "$*"; } # The no-warning row only measures something on a ThreadSanitizer build; on any other binary it is a SKIP by name, never # a PASS, because a plain binary prints no warning whether or not the race is there (arm (f) passes on the unfixed diff --git a/test/statgatecheck.sh b/test/statgatecheck.sh index 863cb28c4..839ed0530 100755 --- a/test/statgatecheck.sh +++ b/test/statgatecheck.sh @@ -49,18 +49,6 @@ note(){ printf ' NOTE %s\n' "$*"; } [ -x "$BIN" ] || { echo "no ripwire binary at $BIN — build first (cmake --build build -j)"; exit 2; } echo "statgatecheck: BIN=$BIN TMP=$TMP" -# L3 (Linux probe): portable stat reader(s). GNU coreutils and BSD/macOS disagree on both the flag and the -# format directives, and the `stat -f FMT ... || stat -c FMT ...` fallback this gate used is a TRAP. On GNU, -# `-f` means FILESYSTEM status and takes NO format argument, so FMT is parsed as a second FILE: measured on -# coreutils 9.11, `stat -f %i FILE` PRINTS a six-line filesystem block for FILE on stdout and exits 1. The -# `||` arm then appends the right number under six lines of junk -- so a string compare fails, a numeric -# compare dies with "integer expression expected", and a `|| echo MISSING` variant reports MISSING forever -# (a gate that then passes by comparing nothing to nothing). Detect the flavour ONCE, use one form. -# (ns precision where the FS/stat supports it: BSD %Fm, GNU %.9Y) -if stat --version >/dev/null 2>&1; then mtime_ns(){ stat -c '%.9Y' "$1" 2>/dev/null; } # GNU coreutils -else mtime_ns(){ stat -f '%Fm' "$1" 2>/dev/null; } # BSD / macOS -fi - # ── case (a): warm no-change run is byte-identical ──────────────────────────────────────────────── WA="$TMP/a"; mkdir -p "$WA"; CA="$TMP/a.bin" printf 'int alpha( void )\n{\n return 1;\n}\n' > "$WA/f.cpp" From 308a988a3b1c71ec0de2fb190eb0238d39ca2856 Mon Sep 17 00:00:00 2001 From: Pedro Algarvio Date: Sun, 20 Sep 2026 12:26:34 +0100 Subject: [PATCH 2/2] test: fold four more gates onto statcompat.sh; close claudeconfigdircheck's own hermetic leak Addresses the two before-merge items from PR #298's review: - cacheisolationcheck.sh, qsnapproducercheck.sh, sidecarsymlinkcheck.sh and tempfilesymlinkcheck.sh hand-rolled the same GNU-vs-BSD stat detect-once block this PR already extracted from twelve other gates. All four now source test/lib/statcompat.sh instead. - claudeconfigdircheck.sh's "UNSET IS UNCHANGED" arm relied on CLAUDE_CONFIG_DIR being absent from the environment rather than clearing it, so an ambiently exported CLAUDE_CONFIG_DIR made the gate write real files into it and then fail against its own contaminated baseline. It now sources test/lib/clean-env.sh first, same as skillinstallcheck.sh and hermesinstallcheck.sh, which had the identical gap and are consolidated onto the same helper instead of a narrower ad hoc unset. CHANGELOG.md folds these into the two stat/env entries covering the whole PR. Verified: all seven touched gates ALL PASS; claudeconfigdircheck.sh reproduces the review's exact leak scenario clean with CLAUDE_CONFIG_DIR exported ambiently; --quality-delta gating=0; determinism + xmllint clean. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ test/cacheisolationcheck.sh | 3 ++- test/claudeconfigdircheck.sh | 1 + test/hermesinstallcheck.sh | 1 + test/qsnapproducercheck.sh | 4 +--- test/sidecarsymlinkcheck.sh | 7 ++----- test/skillinstallcheck.sh | 3 +-- test/tempfilesymlinkcheck.sh | 5 ++--- 8 files changed, 34 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8829226bf..0156e7fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,30 @@ the arm compares normally. When an arm is still refused, the root carries `reaso debug trace. Both `ok=` postures and `reason=` are defined in the legend. Gates: `test/scoutheadconflictcheck.sh` arms T9(a)–(e), `test/mergescoutcheck.sh`. (CodeRabbit review on #295) +### Fixed — sixteen gates now share one GNU/BSD `stat` compat helper instead of a per-gate copy + +`stat -f` is GNU coreutils' filesystem-stat flag, not BSD's format-string flag, so it succeeds with junk +instead of failing — a caller-local `stat -f ... || stat -c ...` one-liner never reaches its own fallback on +Linux. Sixteen gates each hand-rolled the same detect-once-and-redefine fix independently: +`cachehashcheck.sh`, `cachesplitcheck.sh`, `clonecachecheck.sh`, `codexpromptroutecheck.sh`, +`evictioncheck.sh`, `g1freshcheck.sh`, `headsnapcachecheck.sh`, `mcpeditmodecheck.sh`, +`portablecachecheck.sh`, `prcontextcheck.sh`, `qsnapcachecheck.sh`, `qsnapprefetchcheck.sh`, +`statgatecheck.sh`, `cacheisolationcheck.sh`, `qsnapproducercheck.sh`, `sidecarsymlinkcheck.sh` and +`tempfilesymlinkcheck.sh` all now source the new shared `test/lib/statcompat.sh` instead — one place defines +the GNU-vs-BSD `stat` compat logic, not seventeen. + +### Fixed — a gate that varies `HOME=` per invocation could still leak into an ambiently-set agent-home variable + +`CODEX_HOME`/`AGENTS_HOME`/`HERMES_HOME`/`CLAUDE_CONFIG_DIR`/`RIPWIRE_DATA_HOME` override the default an +agent's tools derive from `HOME`, so a gate that only sets `HOME=` per invocation is not actually sandboxed +on a machine where any of these is already exported ambiently. `codexpromptroutecheck.sh`, +`claudeconfigdircheck.sh`, `skillinstallcheck.sh` and `hermesinstallcheck.sh` now source the new shared +`test/lib/clean-env.sh` before varying `HOME=`, closing that leak in each. `claudeconfigdircheck.sh` — the +gate that exists specifically to test `CLAUDE_CONFIG_DIR` relocation — was the one this hit hardest: with +`CLAUDE_CONFIG_DIR` exported ambiently (a developer whose real Claude Code config is relocated, exactly the +case this gate tests for), its "unset" baseline arm wrote real files into that directory and then failed +comparing against its own contaminated baseline. + ### Fixed — an ambiguous `--expand` buried its body behind the ranked map, and the escape hatch was stderr-only Reported by @mariadb-KyleHutchinson in #289: `--expand=SYM` on a name matching more than one definition, in a diff --git a/test/cacheisolationcheck.sh b/test/cacheisolationcheck.sh index d31059e2a..147bf96ec 100755 --- a/test/cacheisolationcheck.sh +++ b/test/cacheisolationcheck.sh @@ -3,6 +3,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" fail=0 @@ -30,7 +31,7 @@ PRIVATE="$SHARED/ripwire" if [ -d "$PRIVATE" ]; then ok "creates a dedicated TMPDIR/ripwire directory"; else no "missing private directory: $PRIVATE"; fi if [ -d "$PRIVATE" ]; then - if stat --version >/dev/null 2>&1; then mode="$( stat -c %a "$PRIVATE" )"; else mode="$( stat -f %Lp "$PRIVATE" )"; fi + mode="$( mode_of "$PRIVATE" )" if [ "$mode" = "700" ]; then ok "private directory mode is 0700"; else no "private directory mode is $mode, expected 700"; fi fi diff --git a/test/claudeconfigdircheck.sh b/test/claudeconfigdircheck.sh index 61f6146ec..ae5204646 100755 --- a/test/claudeconfigdircheck.sh +++ b/test/claudeconfigdircheck.sh @@ -43,6 +43,7 @@ # otherwise fall back to it. Exits non-zero on any failure. Does NOT edit regression.sh. set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/clean-env.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" fail=0 diff --git a/test/hermesinstallcheck.sh b/test/hermesinstallcheck.sh index 94d0bf04c..43be35089 100755 --- a/test/hermesinstallcheck.sh +++ b/test/hermesinstallcheck.sh @@ -13,6 +13,7 @@ # Exits non-zero on any failure. Does NOT edit regression.sh. set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/clean-env.sh" SK="$ROOT/skills" fail=0 ok(){ echo " PASS $1" || { fail=1; echo " FAIL could not write the PASS line for: $1"; }; return 0; } diff --git a/test/qsnapproducercheck.sh b/test/qsnapproducercheck.sh index 9ee02eb92..359085048 100755 --- a/test/qsnapproducercheck.sh +++ b/test/qsnapproducercheck.sh @@ -57,6 +57,7 @@ # Usage: test/qsnapproducercheck.sh | RIPWIRE_BIN=build/ripwire test/qsnapproducercheck.sh set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" QSRC="$ROOT/src/quality.h" @@ -218,9 +219,6 @@ h=14695981039346656037 for c in sys.argv[1].encode(): h=((h^c)*1099511628211)&((1<<64)-1) print("%016x"%h)' "$1"; } blob_for(){ find "$CACHEDIR" -maxdepth 2 -type f -name "ripwire-qsnap-*-$( shakey "$1" ).bin" 2>/dev/null | head -1; } -if stat --version >/dev/null 2>&1; then inode_of(){ stat -c %i "$1" 2>/dev/null; } -else inode_of(){ stat -f %i "$1" 2>/dev/null; } -fi # ── (D) the blob records this tree's identity ────────────────────────────────────────────────────────────── echo "// touch" >> "$REPO/src/use.cpp" # a working-tree change, HEAD untouched diff --git a/test/sidecarsymlinkcheck.sh b/test/sidecarsymlinkcheck.sh index 9ad568b41..d1166ca0a 100755 --- a/test/sidecarsymlinkcheck.sh +++ b/test/sidecarsymlinkcheck.sh @@ -166,6 +166,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" # BOTH seams: regression.sh and every differential run pass the binary POSITIONALLY; RIPWIRE_BIN is the # env form. A gate reading only one of them comes back ALL PASS against whatever is in build/ during a # red-first run against a BASE binary — the exact way a red-first check fakes itself green (archcheck.sh @@ -534,11 +535,7 @@ fi # ── (g) MODE CONTROL: the hand-written mode must equal what ofstream/fopen asked for ────────────────── # umask 000 is what makes this discriminating: under the usual 022 a wrong 0644 is indistinguishable from # the correct 0666. GNU stat and BSD stat disagree about -f, so pick the flavour once (CONTRIBUTING §1). -if stat --version >/dev/null 2>&1; then - fileMode(){ stat -c '%a' "$1"; } -else - fileMode(){ stat -f '%Lp' "$1"; } -fi +fileMode(){ mode_of "$1"; } modeArm() { diff --git a/test/skillinstallcheck.sh b/test/skillinstallcheck.sh index 8fd6ff40c..b47fb893e 100755 --- a/test/skillinstallcheck.sh +++ b/test/skillinstallcheck.sh @@ -10,6 +10,7 @@ # Exits non-zero on any failure. Does NOT edit regression.sh or ~/.claude. set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/clean-env.sh" SK="$ROOT/skills" fail=0 ok(){ echo " PASS $1" || { fail=1; echo " FAIL could not write the PASS line for: $1"; }; return 0; } @@ -18,8 +19,6 @@ no(){ echo " FAIL $1"; fail=1; } [ -f "$SK/install.sh" ] || { echo "no skills/install.sh"; exit 2; } TMP="$( mktemp -d )"; trap 'rm -rf "$TMP"' EXIT -# Each invocation below owns its HOME; inherited agent overrides must not escape it. -unset CODEX_HOME AGENTS_HOME HERMES_HOME DST="$TMP/skills" # ---- 1) install.sh deploys EVERY user-facing shipped skill (the deployment-drift catch) ---- diff --git a/test/tempfilesymlinkcheck.sh b/test/tempfilesymlinkcheck.sh index d4117324c..e1d8e8122 100644 --- a/test/tempfilesymlinkcheck.sh +++ b/test/tempfilesymlinkcheck.sh @@ -42,6 +42,7 @@ set -u ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +. "$ROOT/test/lib/statcompat.sh" BIN="${1:-${RIPWIRE_BIN:-$ROOT/build/ripwire}}" [ "${BIN#/}" = "$BIN" ] && BIN="$ROOT/$BIN" # allow repo-relative RIPWIRE_BIN fail=0 @@ -63,9 +64,7 @@ OUTSIDE_MODE=700 place_outside(){ printf '%s' "$OUTSIDE_BYTES" > "$1"; chmod "$OUTSIDE_MODE" "$1"; } # filemode FILE — permission bits in octal (GNU coreutils stat, else BSD / macOS stat). -if stat --version >/dev/null 2>&1; then filemode(){ stat -c %a "$1" 2>/dev/null; } -else filemode(){ stat -f %Lp "$1" 2>/dev/null; } -fi +filemode(){ mode_of "$1"; } # assert_outside TAG OUTSIDE_FILE — (a) bytes and (b) mode are unchanged. assert_outside(){