From 6e1b916fbb9cb2ffe71d0e5379ff74a76a078d8f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:37:19 +0000 Subject: [PATCH 1/5] fix(hooks): require successful validated pre-push scans --- .github/workflows/rust.yml | 2 + .machine_readable/descriptiles/STATE.a2ml | 6 + docs/PRE-PUSH-GATE.adoc | 40 ++++ hooks/pre-push-gate.sh | 242 +++++++++------------- scripts/install-pre-push-gate.sh | 23 ++ tests/pre-push-gate-test.sh | 118 +++++++++++ 6 files changed, 282 insertions(+), 149 deletions(-) create mode 100644 docs/PRE-PUSH-GATE.adoc create mode 100644 scripts/install-pre-push-gate.sh create mode 100644 tests/pre-push-gate-test.sh diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 80ff6520..1782a086 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -90,6 +90,8 @@ jobs: - run: bash scripts/tests/dispatch-paths.sh - name: Workflow-pin fix scripts keep their contracts run: bash scripts/tests/fix-workflow-pins.sh + - name: Pre-push gate rejects missing and failed scans + run: bash tests/pre-push-gate-test.sh gsbot: name: GSBot build, tests and dependency security diff --git a/.machine_readable/descriptiles/STATE.a2ml b/.machine_readable/descriptiles/STATE.a2ml index b82eaace..30c35b81 100644 --- a/.machine_readable/descriptiles/STATE.a2ml +++ b/.machine_readable/descriptiles/STATE.a2ml @@ -53,3 +53,9 @@ changes = "Main shell mutation paths blocked with exit 78; Rust fleet simulation validation = "Shell containment tests passed; existing E2E 36 passed after supplying PyYAML. Rust tests not run: toolchain unavailable." next = "Read-only estate directive inventory and explicit policy contract. Do not enable execution or increase permissions before local restrictions are enforced." evidence = "docs/AUTOMATION-QUARANTINE.adoc; audits/fleet-wiring-2026-09-26.md" + +[pre-push-enforcement-2026-11-07] +status = "candidate; hosted qualification and consumer deployment pending" +change = "Fail closed on zero scanners, scanner errors, malformed reports, incomplete assail coverage and failed configured consumer execution gates. Preserve custom hooks during installation." +validation = "28 local scanner/runner boundary controls and real Git installation controls pass. A separate actual Hypatia escript run accepted a clean fixture and blocked its changed critical transmute fixture; hosted and consumer deployment evidence is still pending." +contract = "docs/PRE-PUSH-GATE.adoc" diff --git a/docs/PRE-PUSH-GATE.adoc b/docs/PRE-PUSH-GATE.adoc new file mode 100644 index 00000000..3f4837c1 --- /dev/null +++ b/docs/PRE-PUSH-GATE.adoc @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Local pre-push gate +:revdate: 2026-11-07 + +Run `bash scripts/install-pre-push-gate.sh /path/to/consumer` from a reviewed +fleet checkout. The installer uses Git's hooks path, including linked worktrees +and `core.hooksPath`. It refuses to replace a different existing hook. Compose +such a hook explicitly; do not overwrite its checks. A second identical +installation is a no-op. A future upgrade also requires reviewing the existing +copy rather than overwriting custom changes. + +The gate requires `jq`, GNU `timeout`, and at least one available scanner on +the hook's PATH. With the default `auto` selection it runs every available +scanner; a failure from either blocks even if another scanner succeeds. +Set repository config `fleet.prePushScanners` to `hypatia`, `panic-attack`, or +`hypatia panic-attack` to require those particular scanners. An unavailable +required scanner blocks. `fleet.prePushTimeoutSeconds` defaults to 180 and +accepts 1 through 1800 seconds per scanner. + +Hypatia uses `hypatia-cli.sh scan` with JSON output and the critical threshold. +Both its Elixir findings array and Bash submission envelope are validated. +Panic-attack uses `assail --headless --output-format json --fail-on-incomplete`; +its 2.6 report must record complete, non-empty declared coverage. Older reports +with no coverage receipt cannot certify this gate. Unknown formats, severities, +malformed output, scanner errors and timeouts block. Explicit suppressed or +advisory findings do not block; unsuppressed critical findings do. + +Static scanning does not execute changed workflows. Set `fleet.prePushCiRunner` +to an absolute executable path for the consumer's owned execution gate. It runs +in the consumer working tree and receives the original pre-push arguments and +ref-update stdin. A missing or failed configured runner blocks. Without one, +the output explicitly reports static scanning only; it is not local CI parity +or hosted validation. The consumer remains responsible for selecting changed +scripts and fixtures and validating unsupported hosted semantics. + +`bash tests/pre-push-gate-test.sh` uses fixture scanner processes to exercise +failure boundaries, actual Git hook installation and a real script/fixture +execution adapter. Those controls are not deployment evidence or a real scan. +Record actual scanner and consumer execution receipts separately before +claiming a consumer has adopted the full gate. diff --git a/hooks/pre-push-gate.sh b/hooks/pre-push-gate.sh index 65e4d475..d9deffd1 100755 --- a/hooks/pre-push-gate.sh +++ b/hooks/pre-push-gate.sh @@ -1,161 +1,105 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 -# pre-push-gate.sh — Local pre-push hook for static analysis. -# -# Runs panic-attack and hypatia locally before allowing a push. -# Exits non-zero if critical findings are detected. -# -# Installation (symlink into any repo): -# ln -sf /path/to/gitbot-fleet/hooks/pre-push-gate.sh .git/hooks/pre-push -# -# Or copy it: -# cp /path/to/gitbot-fleet/hooks/pre-push-gate.sh .git/hooks/pre-push -# chmod +x .git/hooks/pre-push - +# Fleet static pre-push gate. Install with scripts/install-pre-push-gate.sh. +# A missing, failed or malformed scan is not a successful zero-finding scan. set -euo pipefail -# Colours (disabled when stdout is not a terminal) -if [ -t 1 ]; then - RED='\033[0;31m' - YELLOW='\033[0;33m' - GREEN='\033[0;32m' - BOLD='\033[1m' - RESET='\033[0m' -else - RED='' YELLOW='' GREEN='' BOLD='' RESET='' -fi - -REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -CRITICAL_COUNT=0 -HIGH_COUNT=0 -TOTAL_COUNT=0 -SCANNERS_RUN=0 - -# -------------------------------------------------------------------------- -# Helper: parse JSON finding counts (requires jq) -# -------------------------------------------------------------------------- -count_findings() { - local json_file="$1" - if [ ! -s "$json_file" ] || ! jq empty "$json_file" 2>/dev/null; then - return +fail() { printf 'BLOCKED: %s\n' "$*" >&2; exit 1; } +REPO_ROOT=$(git rev-parse --show-toplevel) || fail 'not a Git working tree' +cd "$REPO_ROOT" +for dependency in jq timeout; do + command -v "$dependency" >/dev/null || fail "$dependency is required" +done + +scanners=$(git config --get fleet.prePushScanners || true) +scanners=${scanners:-auto} +limit=$(git config --get fleet.prePushTimeoutSeconds || true) +limit=${limit:-180} +[[ "$limit" =~ ^[1-9][0-9]{0,3}$ ]] && (( limit <= 1800 )) || + fail 'fleet.prePushTimeoutSeconds must be 1..1800' +tmp=$(mktemp -d) +trap 'rm -rf -- "$tmp"' EXIT +scanners_run=0 critical=0 high=0 total=0 + +run_scanner() { + local scanner=$1 binary=$2 rc=0 counts c h n + printf 'Running %s (timeout %ss)\n' "$scanner" "$limit" + if [[ "$scanner" == panic-attack ]]; then + timeout --kill-after=5s "${limit}s" "$binary" assail "$REPO_ROOT" \ + --headless --output-format json --fail-on-incomplete "$tmp/report" 2>"$tmp/errors" || rc=$? + (( rc == 0 )) || fail "$scanner exited $rc; no successful scan recorded" + else + HYPATIA_FORMAT=json HYPATIA_SEVERITY=critical HYPATIA_EXIT_ZERO=false \ + timeout --kill-after=5s "${limit}s" "$binary" scan "$REPO_ROOT" \ + "$tmp/report" 2>"$tmp/errors" || rc=$? + (( rc == 0 || rc == 1 )) || fail "$scanner exited $rc; no successful scan recorded" fi - local c h m l - c=$(jq '[.[] | select(.severity == "critical")] | length' "$json_file" 2>/dev/null || echo 0) - h=$(jq '[.[] | select(.severity == "high")] | length' "$json_file" 2>/dev/null || echo 0) - m=$(jq '[.[] | select(.severity == "medium")] | length' "$json_file" 2>/dev/null || echo 0) - l=$(jq '[.[] | select(.severity == "low")] | length' "$json_file" 2>/dev/null || echo 0) - CRITICAL_COUNT=$(( CRITICAL_COUNT + c )) - HIGH_COUNT=$(( HIGH_COUNT + h )) - TOTAL_COUNT=$(( TOTAL_COUNT + c + h + m + l )) -} -# -------------------------------------------------------------------------- -# Scanner 1: panic-attack assail -# -------------------------------------------------------------------------- -run_panic_attack() { - if ! command -v panic-attack >/dev/null 2>&1; then - echo -e "${YELLOW}[skip]${RESET} panic-attack not installed — skipping assail" - return + # Slurp rejects multiple JSON documents. Validate the scanner's actual report + # envelope before counting; an error object or an unknown severity is fatal. + if ! counts=$(jq -er -s --arg scanner "$scanner" ' + if length != 1 then error("expected one report") else .[0] end + | if $scanner == "panic-attack" then + if type == "object" and .schema_version == "2.6" and + .coverage.status == "complete" and + (.coverage.analyzed_files | type) == "number" and + .coverage.analyzed_files > 0 and (.weak_points | type) == "array" + then .weak_points else error("invalid or incomplete assail report") end + elif type == "array" then . + elif type == "object" and .submission_metadata.scanner == "hypatia" and + (.findings | type) == "array" and + .submission_metadata.finding_count == (.findings | length) + then .findings + else error("invalid Hypatia report") end + | if all(.[]; type == "object" and (.severity | type) == "string" and + (.severity | ascii_downcase | IN("critical", "high", "medium", "warn", "low", "info")) and + ((has("suppressed") | not) or (.suppressed | type) == "boolean") and + ((has("advisory") | not) or (.advisory | type) == "boolean")) + then . else error("invalid finding") end + | map(select(.suppressed != true and .advisory != true) | .severity |= ascii_downcase) + | [(map(select(.severity == "critical")) | length), + (map(select(.severity == "high")) | length), length] | @tsv + ' "$tmp/report" 2>/dev/null); then + fail "$scanner returned an invalid or incomplete report; no successful scan recorded" fi - - echo -e "${BOLD}[1/2]${RESET} Running panic-attack assail..." - local tmp - tmp=$(mktemp /tmp/pa-findings-XXXXXX.json) - - set +e - panic-attack assail --format json "$REPO_ROOT" > "$tmp" 2>/dev/null - local rc=$? - set -e - - if [ "$rc" -ne 0 ] && [ ! -s "$tmp" ]; then - echo -e "${YELLOW}[warn]${RESET} panic-attack exited $rc with no output" - rm -f "$tmp" - return + IFS=$'\t' read -r c h n <<< "$counts" + if [[ "$scanner" == hypatia && "$rc" == 1 && "$c" == 0 ]]; then + fail 'Hypatia failed without a blocking critical finding' fi - - count_findings "$tmp" - SCANNERS_RUN=$(( SCANNERS_RUN + 1 )) - - local pa_total - pa_total=$(jq '. | length' "$tmp" 2>/dev/null || echo 0) - echo -e " panic-attack: ${pa_total} finding(s)" - rm -f "$tmp" + critical=$((critical + c)); high=$((high + h)); total=$((total + n)) + scanners_run=$((scanners_run + 1)) + printf '%s: validated report, %s unsuppressed findings, %s critical\n' "$scanner" "$n" "$c" } -# -------------------------------------------------------------------------- -# Scanner 2: hypatia-cli.sh scan -# -------------------------------------------------------------------------- -run_hypatia() { - # Look for hypatia-cli.sh in common locations - local hypatia_bin="" - for candidate in \ - "$(command -v hypatia-cli.sh 2>/dev/null || true)" \ - "$HOME/hypatia/hypatia-cli.sh" \ - "$HOME/.local/bin/hypatia-cli.sh"; do - if [ -x "$candidate" ]; then - hypatia_bin="$candidate" - break - fi - done - - if [ -z "$hypatia_bin" ]; then - echo -e "${YELLOW}[skip]${RESET} hypatia-cli.sh not found — skipping scan" - return +declare -A seen=() +if [[ "$scanners" == auto ]]; then selected='panic-attack hypatia'; else selected=$scanners; fi +read -r -a selected_scanners <<< "$selected" +for scanner in "${selected_scanners[@]}"; do + [[ -z "${seen[$scanner]:-}" ]] || continue + seen[$scanner]=1 + case "$scanner" in + panic-attack) binary=$(command -v panic-attack || true) ;; + hypatia) binary=$(command -v hypatia-cli.sh || true) ;; + *) fail "unknown configured scanner: $scanner" ;; + esac + if [[ -z "$binary" ]]; then + [[ "$scanners" == auto ]] || fail "required scanner $scanner is unavailable" + printf 'Unavailable: %s\n' "$scanner" + continue fi - - echo -e "${BOLD}[2/2]${RESET} Running hypatia scan..." - local tmp - tmp=$(mktemp /tmp/hyp-findings-XXXXXX.json) - - set +e - HYPATIA_FORMAT=json "$hypatia_bin" scan "$REPO_ROOT" > "$tmp" 2>/dev/null - local rc=$? - set -e - - if [ "$rc" -ne 0 ] && [ ! -s "$tmp" ]; then - echo -e "${YELLOW}[warn]${RESET} hypatia exited $rc with no output" - rm -f "$tmp" - return - fi - - count_findings "$tmp" - SCANNERS_RUN=$(( SCANNERS_RUN + 1 )) - - local hyp_total - hyp_total=$(jq '. | length' "$tmp" 2>/dev/null || echo 0) - echo -e " hypatia: ${hyp_total} finding(s)" - rm -f "$tmp" -} - -# -------------------------------------------------------------------------- -# Main -# -------------------------------------------------------------------------- -echo -e "${BOLD}Static Analysis Gate (pre-push)${RESET}" -echo "Repository: $REPO_ROOT" -echo "" - -run_panic_attack -run_hypatia - -echo "" -echo -e "${BOLD}--- Summary ---${RESET}" -echo "Scanners run : $SCANNERS_RUN" -echo "Total findings: $TOTAL_COUNT" -echo " Critical : $CRITICAL_COUNT" -echo " High : $HIGH_COUNT" - -if [ "$SCANNERS_RUN" -eq 0 ]; then - echo "" - echo -e "${YELLOW}No scanners available — push allowed (install panic-attack or hypatia for local gating).${RESET}" - exit 0 -fi - -if [ "$CRITICAL_COUNT" -gt 0 ]; then - echo "" - echo -e "${RED}BLOCKED: $CRITICAL_COUNT critical finding(s) detected. Fix before pushing.${RESET}" - exit 1 + run_scanner "$scanner" "$binary" +done +(( scanners_run > 0 )) || fail 'no scanner completed; install Hypatia or panic-attack' +printf 'Completed scanners: %s; findings: %s; critical: %s; high: %s\n' \ + "$scanners_run" "$total" "$critical" "$high" +(( critical == 0 )) || fail "$critical critical finding(s); fix before pushing" + +# A consumer may configure an executable adapter for its owned CI receipt gate. +# Pass the Git hook arguments and untouched ref-update stdin through to it. +ci_runner=$(git config --get fleet.prePushCiRunner || true) +if [[ -n "$ci_runner" ]]; then + [[ "$ci_runner" = /* && -x "$ci_runner" ]] || fail 'configured CI runner must be an executable absolute path' + "$ci_runner" "$@" || fail 'consumer CI execution gate did not pass' +else + printf 'Static scan passed; no consumer CI execution gate is configured.\n' fi - -echo "" -echo -e "${GREEN}No critical findings — push allowed.${RESET}" -exit 0 diff --git a/scripts/install-pre-push-gate.sh b/scripts/install-pre-push-gate.sh new file mode 100644 index 00000000..90d91d94 --- /dev/null +++ b/scripts/install-pre-push-gate.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Install without replacing an existing custom hook or changing core.hooksPath. +set -euo pipefail +repo=${1:?usage: install-pre-push-gate.sh REPOSITORY} +source_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +repo=$(git -C "$repo" rev-parse --show-toplevel) +hook=$(git -C "$repo" rev-parse --path-format=absolute --git-path hooks/pre-push) +source_hook="$source_root/hooks/pre-push-gate.sh" +if [[ -e "$hook" || -L "$hook" ]]; then + if cmp -s "$source_hook" "$hook" && [[ -x "$hook" ]]; then + printf 'Already installed: %s\n' "$hook" + exit 0 + fi + printf 'Refusing to replace existing hook: %s\nCompose it with the fleet gate explicitly.\n' "$hook" >&2 + exit 1 +fi +mkdir -p "$(dirname "$hook")" +# noclobber prevents replacing a hook created after the check above. +(set -o noclobber; cat "$source_hook" > "$hook") +chmod +x "$hook" +printf 'Installed: %s\n' "$hook" +printf 'The gate requires a working scanner. Configure fleet.prePushScanners to require a particular scanner, and fleet.prePushCiRunner for the consumer CI receipt gate.\n' diff --git a/tests/pre-push-gate-test.sh b/tests/pre-push-gate-test.sh new file mode 100644 index 00000000..92189580 --- /dev/null +++ b/tests/pre-push-gate-test.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Offline boundary controls: scanner processes are fixtures, Git and hooks are real. +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +gate="$root/hooks/pre-push-gate.sh" +installer="$root/scripts/install-pre-push-gate.sh" +scratch=$(mktemp -d) +trap 'rm -rf -- "$scratch"' EXIT +mkdir "$scratch/bin" "$scratch/repo with spaces" +for tool in git jq timeout mktemp rm; do ln -s "$(command -v "$tool")" "$scratch/bin/$tool"; done +git init -q "$scratch/repo with spaces" +cd "$scratch/repo with spaces" +passed=0 +export SCANNER_EXIT=0 REPORT_PAYLOAD='[]' +run_gate() { + local expected=$1 label=$2 rc=0 + printf 'refs/heads/main 123 refs/heads/main 456\n' | + PATH="$scratch/bin" /bin/bash "$gate" origin fixture-remote >"$scratch/result" 2>&1 || rc=$? + if { [[ "$expected" == pass ]] && (( rc != 0 )); } || + { [[ "$expected" == fail ]] && (( rc == 0 )); }; then + cat "$scratch/result" >&2 + printf 'FAIL: %s (exit %s)\n' "$label" "$rc" >&2 + exit 1 + fi + passed=$((passed + 1)) + printf 'ok %s: %s\n' "$passed" "$label" +} +run_gate fail 'zero scanners blocks' +cat > "$scratch/bin/hypatia-cli.sh" <<'SCANNER' +#!/bin/bash +[[ "$1" == scan && -d "$2" && "$HYPATIA_FORMAT" == json && "$HYPATIA_SEVERITY" == critical ]] || exit 77 +printf '%s\n' "$REPORT_PAYLOAD" +exit "$SCANNER_EXIT" +SCANNER +chmod +x "$scratch/bin/hypatia-cli.sh" +run_gate pass 'valid empty Hypatia array succeeds' +REPORT_PAYLOAD='{"message":"failed"}' run_gate fail 'error JSON is not a scan' +REPORT_PAYLOAD='not JSON' run_gate fail 'malformed output blocks' +REPORT_PAYLOAD='' run_gate fail 'empty output blocks' +REPORT_PAYLOAD='[] []' run_gate fail 'multiple reports block' +REPORT_PAYLOAD='[{}]' run_gate fail 'missing severity blocks' +REPORT_PAYLOAD='[{"severity":"typo"}]' run_gate fail 'unknown severity blocks' +REPORT_PAYLOAD='[{"severity":"critical"}]' SCANNER_EXIT=1 run_gate fail 'critical finding blocks' +REPORT_PAYLOAD='[{"severity":"CRITICAL"}]' run_gate fail 'case cannot hide critical finding' +REPORT_PAYLOAD='[{"severity":"critical","advisory":"true"}]' run_gate fail 'malformed advisory flag blocks' +REPORT_PAYLOAD='[{"severity":"critical","advisory":true}]' run_gate pass 'explicit advisory is not blocking' +SCANNER_EXIT=2 run_gate fail 'scanner failure with valid empty array blocks' +SCANNER_EXIT=1 run_gate fail 'failure without blocking finding blocks' +REPORT_PAYLOAD='{"submission_metadata":{"scanner":"hypatia","finding_count":0},"findings":[]}' run_gate pass 'Bash Hypatia envelope succeeds' +REPORT_PAYLOAD='{"submission_metadata":{"scanner":"hypatia","finding_count":1},"findings":[]}' run_gate fail 'inconsistent envelope count blocks' +git config fleet.prePushScanners 'hypatia panic-attack' +run_gate fail 'required missing scanner blocks despite another success' +git config fleet.prePushScanners panic-attack +cat > "$scratch/bin/panic-attack" <<'SCANNER' +#!/bin/bash +[[ "$1" == assail && "$3" == --headless && "$4" == --output-format && "$5" == json && "$6" == --fail-on-incomplete ]] || exit 77 +printf '%s\n' "$REPORT_PAYLOAD" +exit "$SCANNER_EXIT" +SCANNER +chmod +x "$scratch/bin/panic-attack" +REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"complete","analyzed_files":1},"weak_points":[]}' run_gate pass 'actual assail envelope and command flags' +REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"complete","analyzed_files":1},"weak_points":[{"severity":"Critical"}]}' run_gate fail 'assail uppercase critical blocks' +REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"complete","analyzed_files":1},"weak_points":[{"severity":"Critical","suppressed":true}]}' run_gate pass 'assail explicit suppression retained' +REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"partial","analyzed_files":1},"weak_points":[]}' run_gate fail 'incomplete coverage blocks' +REPORT_PAYLOAD='{"schema_version":"2.5","weak_points":[]}' run_gate fail 'legacy unmeasured coverage is not complete' +REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"complete","analyzed_files":1},"weak_points":[]}' SCANNER_EXIT=1 run_gate fail 'assail failure cannot be replaced by a valid report' +git config fleet.prePushScanners hypatia +git config fleet.prePushTimeoutSeconds 1 +cp "$scratch/bin/hypatia-cli.sh" "$scratch/hypatia-good" +printf '#!/bin/bash\n/bin/sleep 5\nprintf "[]\\n"\n' > "$scratch/bin/hypatia-cli.sh" +run_gate fail 'timeout blocks' +cp "$scratch/hypatia-good" "$scratch/bin/hypatia-cli.sh" +git config --unset fleet.prePushTimeoutSeconds + +cat > "$scratch/ci-runner" <<'RUNNER' +#!/bin/bash +set -euo pipefail +[[ "$1" == origin && "$2" == fixture-remote ]] +IFS= read -r update +[[ "$update" == 'refs/heads/main 123 refs/heads/main 456' ]] +/bin/bash script-under-test.sh +RUNNER +chmod +x "$scratch/ci-runner" +git config fleet.prePushCiRunner "$scratch/ci-runner" +# The isolated PATH deliberately lacks cat; the executed script uses /bin/cat. +printf 'test "$(/bin/cat fixture.txt)" = accepted\n' > script-under-test.sh +printf 'accepted\n' > fixture.txt +run_gate pass 'consumer runner receives hook args and ref stdin and executes script' +printf 'rejected\n' > fixture.txt +run_gate fail 'changed fixture is executed and blocks' +printf 'exit 23\n' > script-under-test.sh +run_gate fail 'changed script exit propagates' +git config fleet.prePushCiRunner "$scratch/absent" +run_gate fail 'missing configured CI runner blocks' +git config --unset fleet.prePushCiRunner + +bash "$installer" "$PWD" > "$scratch/install" +cmp "$gate" .git/hooks/pre-push +bash "$installer" "$PWD" > "$scratch/reinstall" +printf '# user hook\n' >> .git/hooks/pre-push +cp .git/hooks/pre-push "$scratch/preserved" +if bash "$installer" "$PWD" > "$scratch/conflict" 2>&1; then + echo 'FAIL: installer overwrote custom hook' >&2; exit 1 +fi +cmp "$scratch/preserved" .git/hooks/pre-push +git config core.hooksPath '.custom hooks' +bash "$installer" "$PWD" > "$scratch/custom" +cmp "$gate" '.custom hooks/pre-push' +git config --unset core.hooksPath +git -c user.name='Hook fixture' -c user.email='fixture@example.invalid' -c commit.gpgsign=false commit --allow-empty -qm fixture +git worktree add -q "$scratch/linked" -b linked +# Existing custom hook is still preserved when reached through a worktree. +if bash "$installer" "$scratch/linked" > "$scratch/linked-conflict" 2>&1; then + echo 'FAIL: linked worktree overwrote shared hook' >&2; exit 1 +fi +cmp "$scratch/preserved" .git/hooks/pre-push +printf 'PASS: %s scanner/runner controls; installation, idempotence, custom hooksPath and worktree preservation\n' "$passed" From 4c9782334a8f4d5a4293e6b8cf0ceae3ca6b511c Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:51:57 +0000 Subject: [PATCH 2/5] docs(hooks): document pre-push scanner validation and failure handling --- hooks/pre-push-gate.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hooks/pre-push-gate.sh b/hooks/pre-push-gate.sh index d9deffd1..cca76388 100755 --- a/hooks/pre-push-gate.sh +++ b/hooks/pre-push-gate.sh @@ -4,6 +4,7 @@ # A missing, failed or malformed scan is not a successful zero-finding scan. set -euo pipefail +# Report the supplied reason to stderr and exit the hook with status 1. fail() { printf 'BLOCKED: %s\n' "$*" >&2; exit 1; } REPO_ROOT=$(git rev-parse --show-toplevel) || fail 'not a Git working tree' cd "$REPO_ROOT" @@ -21,6 +22,12 @@ tmp=$(mktemp -d) trap 'rm -rf -- "$tmp"' EXIT scanners_run=0 critical=0 high=0 total=0 +# Run the named scanner (panic-attack or hypatia) with the given executable +# and configured timeout in seconds. Add non-suppressed, non-advisory findings +# to the global counts and increment scanners_run after validating the report. +# Exit the hook on timeout, scanner failure, or invalid or incomplete output. +# Hypatia exit 1 is accepted only with a non-suppressed, non-advisory critical +# finding; the caller blocks on critical findings after scanning. run_scanner() { local scanner=$1 binary=$2 rc=0 counts c h n printf 'Running %s (timeout %ss)\n' "$scanner" "$limit" From aa3794e7ee48f7c4e4569cca7fc08324b0246e14 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:52:08 +0000 Subject: [PATCH 3/5] fix(hooks): deduplicate pre-push scanners without associative arrays --- hooks/pre-push-gate.sh | 6 +++--- tests/pre-push-gate-test.sh | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/hooks/pre-push-gate.sh b/hooks/pre-push-gate.sh index cca76388..92269e67 100755 --- a/hooks/pre-push-gate.sh +++ b/hooks/pre-push-gate.sh @@ -78,12 +78,12 @@ run_scanner() { printf '%s: validated report, %s unsuppressed findings, %s critical\n' "$scanner" "$n" "$c" } -declare -A seen=() +seen=' ' if [[ "$scanners" == auto ]]; then selected='panic-attack hypatia'; else selected=$scanners; fi read -r -a selected_scanners <<< "$selected" for scanner in "${selected_scanners[@]}"; do - [[ -z "${seen[$scanner]:-}" ]] || continue - seen[$scanner]=1 + case "$seen" in *" $scanner "*) continue ;; esac + seen="$seen$scanner " case "$scanner" in panic-attack) binary=$(command -v panic-attack || true) ;; hypatia) binary=$(command -v hypatia-cli.sh || true) ;; diff --git a/tests/pre-push-gate-test.sh b/tests/pre-push-gate-test.sh index 92189580..71b6d0c2 100644 --- a/tests/pre-push-gate-test.sh +++ b/tests/pre-push-gate-test.sh @@ -35,6 +35,13 @@ exit "$SCANNER_EXIT" SCANNER chmod +x "$scratch/bin/hypatia-cli.sh" run_gate pass 'valid empty Hypatia array succeeds' +git config fleet.prePushScanners 'hypatia hypatia hypatia' +run_gate pass 'repeated Hypatia runs once' +[[ $(grep -c '^Running hypatia ' "$scratch/result") == 1 ]] +git config fleet.prePushScanners 'hypatia hyp' +run_gate fail 'scanner prefix is not mistaken for a duplicate' +grep -q 'unknown configured scanner: hyp$' "$scratch/result" +git config --unset fleet.prePushScanners REPORT_PAYLOAD='{"message":"failed"}' run_gate fail 'error JSON is not a scan' REPORT_PAYLOAD='not JSON' run_gate fail 'malformed output blocks' REPORT_PAYLOAD='' run_gate fail 'empty output blocks' @@ -60,6 +67,10 @@ exit "$SCANNER_EXIT" SCANNER chmod +x "$scratch/bin/panic-attack" REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"complete","analyzed_files":1},"weak_points":[]}' run_gate pass 'actual assail envelope and command flags' +git config fleet.prePushScanners 'panic-attack panic-attack panic-attack' +REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"complete","analyzed_files":1},"weak_points":[]}' run_gate pass 'repeated panic-attack runs once' +[[ $(grep -c '^Running panic-attack ' "$scratch/result") == 1 ]] +git config fleet.prePushScanners panic-attack REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"complete","analyzed_files":1},"weak_points":[{"severity":"Critical"}]}' run_gate fail 'assail uppercase critical blocks' REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"complete","analyzed_files":1},"weak_points":[{"severity":"Critical","suppressed":true}]}' run_gate pass 'assail explicit suppression retained' REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"partial","analyzed_files":1},"weak_points":[]}' run_gate fail 'incomplete coverage blocks' From d0a455b3d3def1c9b9f8bfcf472c8042cd36dc67 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:53:05 +0000 Subject: [PATCH 4/5] test(hooks): expand pre-push scanner, CI runner, and hook installation coverage --- tests/pre-push-gate-test.sh | 128 +++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/tests/pre-push-gate-test.sh b/tests/pre-push-gate-test.sh index 71b6d0c2..4b214ae8 100644 --- a/tests/pre-push-gate-test.sh +++ b/tests/pre-push-gate-test.sh @@ -13,9 +13,10 @@ git init -q "$scratch/repo with spaces" cd "$scratch/repo with spaces" passed=0 export SCANNER_EXIT=0 REPORT_PAYLOAD='[]' +default_updates=$'refs/heads/main 123 refs/heads/main 456\n' run_gate() { local expected=$1 label=$2 rc=0 - printf 'refs/heads/main 123 refs/heads/main 456\n' | + printf '%s' "${REF_UPDATES:-$default_updates}" | PATH="$scratch/bin" /bin/bash "$gate" origin fixture-remote >"$scratch/result" 2>&1 || rc=$? if { [[ "$expected" == pass ]] && (( rc != 0 )); } || { [[ "$expected" == fail ]] && (( rc == 0 )); }; then @@ -23,6 +24,11 @@ run_gate() { printf 'FAIL: %s (exit %s)\n' "$label" "$rc" >&2 exit 1 fi + if [[ -n "${3:-}" ]] && ! /bin/grep -Fq -- "$3" "$scratch/result"; then + cat "$scratch/result" >&2 + printf 'FAIL: %s (missing output: %s)\n' "$label" "$3" >&2 + exit 1 + fi passed=$((passed + 1)) printf 'ok %s: %s\n' "$passed" "$label" } @@ -56,6 +62,45 @@ SCANNER_EXIT=2 run_gate fail 'scanner failure with valid empty array blocks' SCANNER_EXIT=1 run_gate fail 'failure without blocking finding blocks' REPORT_PAYLOAD='{"submission_metadata":{"scanner":"hypatia","finding_count":0},"findings":[]}' run_gate pass 'Bash Hypatia envelope succeeds' REPORT_PAYLOAD='{"submission_metadata":{"scanner":"hypatia","finding_count":1},"findings":[]}' run_gate fail 'inconsistent envelope count blocks' +# Verify severity thresholds, explicit flags and malformed Hypatia reports. +for severity in high medium warn low info; do + REPORT_PAYLOAD=$(jq -cn --arg severity "$severity" '[{severity:$severity}]') \ + run_gate pass "$severity is below the blocking threshold" 'findings: 1; critical: 0' +done +for flag in suppressed advisory; do + REPORT_PAYLOAD=$(jq -cn --arg flag "$flag" '[{severity:"critical",($flag):true}]') \ + run_gate pass "$flag excludes critical from totals" 'findings: 0; critical: 0' + REPORT_PAYLOAD=$(jq -cn --arg flag "$flag" '[{severity:"critical",($flag):false}]') \ + run_gate fail "explicit false $flag retains critical" '1 critical finding(s)' + for value in null 0 '"true"'; do + REPORT_PAYLOAD=$(jq -cn --arg flag "$flag" --argjson value "$value" \ + '[{severity:"info",($flag):$value}]') \ + run_gate fail "nonboolean $flag=$value blocks" 'invalid or incomplete report' + done +done +for payload in null true 0 '"text"' '[null]' '[1]' '[{"severity":null}]' \ + '[{"severity":42}]' \ + '{"submission_metadata":{"scanner":"other","finding_count":0},"findings":[]}' \ + '{"submission_metadata":{"scanner":"hypatia","finding_count":"0"},"findings":[]}'; do + REPORT_PAYLOAD=$payload run_gate fail "invalid Hypatia schema: $payload" 'invalid or incomplete report' +done +REPORT_PAYLOAD='{"submission_metadata":{"scanner":"hypatia","finding_count":1},"findings":[{"severity":"critical"}]}' \ + SCANNER_EXIT=1 run_gate fail 'Hypatia envelope preserves finding exit and threshold' '1 critical finding(s)' +REPORT_PAYLOAD='[{"severity":"critical","suppressed":true}]' SCANNER_EXIT=1 \ + run_gate fail 'suppression cannot hide a Hypatia failure exit' 'Hypatia failed without a blocking critical finding' +for limit in 1 1800; do + git config fleet.prePushTimeoutSeconds "$limit" + run_gate pass "timeout boundary $limit is accepted" "timeout ${limit}s" +done +for limit in 0 1801 -1 01 1.5 invalid; do + git config fleet.prePushTimeoutSeconds "$limit" + run_gate fail "invalid timeout $limit blocks" 'must be 1..1800' +done +git config --unset fleet.prePushTimeoutSeconds +git config fleet.prePushScanners unknown +run_gate fail 'unknown scanner blocks' 'unknown configured scanner: unknown' +git config fleet.prePushScanners 'hypatia hypatia' +run_gate pass 'duplicate scanner runs once' 'Completed scanners: 1;' git config fleet.prePushScanners 'hypatia panic-attack' run_gate fail 'required missing scanner blocks despite another success' git config fleet.prePushScanners panic-attack @@ -76,6 +121,35 @@ REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"complete","analyze REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"partial","analyzed_files":1},"weak_points":[]}' run_gate fail 'incomplete coverage blocks' REPORT_PAYLOAD='{"schema_version":"2.5","weak_points":[]}' run_gate fail 'legacy unmeasured coverage is not complete' REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"complete","analyzed_files":1},"weak_points":[]}' SCANNER_EXIT=1 run_gate fail 'assail failure cannot be replaced by a valid report' +# Coverage and findings are validated independently of scanner exit success. +for coverage in '{}' '{"status":"complete"}' '{"status":"complete","analyzed_files":0}' \ + '{"status":"complete","analyzed_files":-1}' '{"status":"complete","analyzed_files":"1"}' \ + '{"status":"complete","analyzed_files":null}'; do + REPORT_PAYLOAD=$(jq -cn --argjson coverage "$coverage" \ + '{schema_version:"2.6",coverage:$coverage,weak_points:[]}') \ + run_gate fail "invalid assail coverage: $coverage" 'invalid or incomplete report' +done +for findings in null '{}' '[{"severity":"unknown"}]' '[{"severity":"critical","suppressed":null}]'; do + REPORT_PAYLOAD=$(jq -cn --argjson findings "$findings" \ + '{schema_version:"2.6",coverage:{status:"complete",analyzed_files:1},weak_points:$findings}') \ + run_gate fail "invalid assail findings: $findings" 'invalid or incomplete report' +done +REPORT_PAYLOAD='{"schema_version":"2.6","coverage":{"status":"complete","analyzed_files":1},"weak_points":[{"severity":"HIGH"},{"severity":"critical","advisory":true}]}' \ + run_gate pass 'assail high is counted and advisory critical excluded' 'findings: 1; critical: 0; high: 1' + +# Auto selection must run both available scanners and retain either failure. +# This array is valid Hypatia output but invalid panic-attack output. +git config --unset fleet.prePushScanners +run_gate fail 'auto cannot ignore a failed scanner when another is available' 'panic-attack returned an invalid' +cp "$scratch/bin/panic-attack" "$scratch/panic-good" +cat > "$scratch/bin/panic-attack" <<'SCANNER' +#!/bin/bash +printf '%s\n' '{"schema_version":"2.6","coverage":{"status":"complete","analyzed_files":1},"weak_points":[{"severity":"high"}]}' +SCANNER +REPORT_PAYLOAD='[{"severity":"high"}]' run_gate pass 'auto aggregates both scanners' \ + 'Completed scanners: 2; findings: 2; critical: 0; high: 2' +REPORT_PAYLOAD='[{"severity":"critical"}]' run_gate fail 'Hypatia critical survives noncritical assail scan' '1 critical finding(s)' +cp "$scratch/panic-good" "$scratch/bin/panic-attack" git config fleet.prePushScanners hypatia git config fleet.prePushTimeoutSeconds 1 cp "$scratch/bin/hypatia-cli.sh" "$scratch/hypatia-good" @@ -104,10 +178,35 @@ printf 'exit 23\n' > script-under-test.sh run_gate fail 'changed script exit propagates' git config fleet.prePushCiRunner "$scratch/absent" run_gate fail 'missing configured CI runner blocks' +git config fleet.prePushCiRunner ci-runner +run_gate fail 'relative CI runner path blocks' 'executable absolute path' +chmod -x "$scratch/ci-runner" +git config fleet.prePushCiRunner "$scratch/ci-runner" +run_gate fail 'nonexecutable CI runner blocks' 'executable absolute path' +chmod +x "$scratch/ci-runner" +cat > "$scratch/ci-runner" <<'RUNNER' +#!/bin/bash +[[ "$PWD" == "$EXPECTED_ROOT" ]] || exit 81 +/bin/cat > "$CAPTURED_UPDATES" +RUNNER +export EXPECTED_ROOT="$PWD" CAPTURED_UPDATES="$scratch/received-updates" +REF_UPDATES=$'refs/heads/main 123 refs/heads/main 456\nrefs/heads/topic 789 refs/heads/topic 000\n' \ + run_gate pass 'runner receives every update unchanged' +printf '%s\n' 'refs/heads/main 123 refs/heads/main 456' \ + 'refs/heads/topic 789 refs/heads/topic 000' > "$scratch/expected-updates" +cmp "$scratch/expected-updates" "$CAPTURED_UPDATES" +# A blocked scan must prevent the runner from executing. +rm "$CAPTURED_UPDATES" +REPORT_PAYLOAD='[{"severity":"critical"}]' \ + run_gate fail 'critical findings prevent CI execution' '1 critical finding(s)' +[[ ! -e "$CAPTURED_UPDATES" ]] git config --unset fleet.prePushCiRunner +run_gate pass 'unconfigured runner explicitly limits success to static scanning' \ + 'Static scan passed; no consumer CI execution gate is configured.' bash "$installer" "$PWD" > "$scratch/install" cmp "$gate" .git/hooks/pre-push +[[ -x .git/hooks/pre-push ]] bash "$installer" "$PWD" > "$scratch/reinstall" printf '# user hook\n' >> .git/hooks/pre-push cp .git/hooks/pre-push "$scratch/preserved" @@ -118,6 +217,20 @@ cmp "$scratch/preserved" .git/hooks/pre-push git config core.hooksPath '.custom hooks' bash "$installer" "$PWD" > "$scratch/custom" cmp "$gate" '.custom hooks/pre-push' +# Absolute paths, dangling symlinks and identical nonexecutable files are +# separate preservation cases: the installer must not repair or replace them. +git config core.hooksPath "$scratch/absolute hooks" +bash "$installer" "$PWD" > "$scratch/absolute-install" +cmp "$gate" "$scratch/absolute hooks/pre-push" +[[ -x "$scratch/absolute hooks/pre-push" ]] +rm "$scratch/absolute hooks/pre-push" +cp "$gate" "$scratch/absolute hooks/pre-push" +chmod -x "$scratch/absolute hooks/pre-push" +if bash "$installer" "$PWD" > "$scratch/nonexecutable-conflict" 2>&1; then + echo 'FAIL: installer changed nonexecutable hook' >&2; exit 1 +fi +cmp "$gate" "$scratch/absolute hooks/pre-push" +[[ ! -x "$scratch/absolute hooks/pre-push" ]] git config --unset core.hooksPath git -c user.name='Hook fixture' -c user.email='fixture@example.invalid' -c commit.gpgsign=false commit --allow-empty -qm fixture git worktree add -q "$scratch/linked" -b linked @@ -126,4 +239,17 @@ if bash "$installer" "$scratch/linked" > "$scratch/linked-conflict" 2>&1; then echo 'FAIL: linked worktree overwrote shared hook' >&2; exit 1 fi cmp "$scratch/preserved" .git/hooks/pre-push +# Git may resolve a dangling hook symlink to its target when producing an +# absolute hooks path. Refuse installation without creating that target. +git config core.hooksPath "$scratch/absolute hooks" +rm "$scratch/absolute hooks/pre-push" +ln -s "$scratch/missing-hook" "$scratch/absolute hooks/pre-push" +if bash "$installer" "$PWD" > "$scratch/symlink-conflict" 2>&1; then + cat "$scratch/symlink-conflict" >&2 + echo 'FAIL: installer followed dangling hook symlink and wrote its target' >&2 + exit 1 +fi +[[ -L "$scratch/absolute hooks/pre-push" ]] +[[ "$(readlink "$scratch/absolute hooks/pre-push")" == "$scratch/missing-hook" ]] +[[ ! -e "$scratch/missing-hook" ]] printf 'PASS: %s scanner/runner controls; installation, idempotence, custom hooksPath and worktree preservation\n' "$passed" From a01d278c8b642eeec59d9319cd79c234326ac30e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:16:08 +0000 Subject: [PATCH 5/5] fix(hooks): preserve dangling hooks and adopt the current canon table --- bots/rhodibot/canon/pin.toml | 14 +++++++----- .../canon/template-capability-gates.toml | 12 +++++++--- bots/rhodibot/src/canon/profile.rs | 2 +- docs/BRANCH-PROTECTION-SETUP.adoc | 22 ++++++++----------- scripts/install-pre-push-gate.sh | 5 ++++- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/bots/rhodibot/canon/pin.toml b/bots/rhodibot/canon/pin.toml index a2e668d0..5766b97c 100644 --- a/bots/rhodibot/canon/pin.toml +++ b/bots/rhodibot/canon/pin.toml @@ -39,8 +39,9 @@ categories = 11 criteria = 78 weight_sum = 88 -# Re-pinned 2026-10-27: the table changed upstream without a version bump -# (still 0.3.0), so the digest, not the version, is what moved. +# Re-pinned 2026-11-07 from standards commit +# fe57d352f0b1a50e5cb07b4aac2581caf355388e: table 0.3.1 recognizes native DEED +# declarations and existing Agda/container layouts; capability counts are unchanged. # # The release's second artefact: the gate table. It says which capabilities a # profile may declare, what a preset expands to, and which module paths belong @@ -49,13 +50,14 @@ weight_sum = 88 # drift script checks that all three agree, because a pin nothing verifies is # not a binding. [gates] -repo = "hyperpolymath/standards" +repo = "metadatastician/standards" +commit = "fe57d352f0b1a50e5cb07b4aac2581caf355388e" path = ".machine_readable/template-capability-gates.toml" slot = "gates" -version = "0.3.0" +version = "0.3.1" released = "2026-09-19" -pinned = "2026-10-27" -sha256 = "2f31f46d4753432e0aa8c9c28f339e18e062c98dc6e6ef4ecbb76993cf37638b" +pinned = "2026-11-07" +sha256 = "5c510bba025be718deebb12d0333bfe7db2da43c66578b728b1a334df65290b1" # The shape of the pinned table. A vocabulary that shrank would leave profiles # declaring the lost words unparseable, and criteria gated on them permanently diff --git a/bots/rhodibot/canon/template-capability-gates.toml b/bots/rhodibot/canon/template-capability-gates.toml index eaae81b1..89432153 100644 --- a/bots/rhodibot/canon/template-capability-gates.toml +++ b/bots/rhodibot/canon/template-capability-gates.toml @@ -10,7 +10,7 @@ # Arrays are kept single-line so the checker can parse them with grep. [meta] -version = "0.3.0" +version = "0.3.1" policy = "0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc" [capabilities] @@ -42,6 +42,9 @@ known = ["rust", "zig", "agda", "idris2", "haskell", "gleam", "elixir", "affines # (#851); the root spelling is kept for repos that have not moved. # * 3-practice/: hyperpolymath/standards keeps its own contributing and # conduct guides in a district, not at the root of a 45-directory monorepo. +# A single repo chora deed may carry the manifest and descriptive records. +# The checker recognizes their substantive native AST clauses, not a wildcard +# filename alternative: malformed, empty and wrong-repository records fail. paths = ["README.adoc", "EXPLAINME.adoc|docs/EXPLAINME.adoc", "LICENSE", "SECURITY.md|.github/SECURITY.md", "CONTRIBUTING.md|.github/CONTRIBUTING.md|CONTRIBUTING.adoc|3-practice/CONTRIBUTING.adoc", "CODE_OF_CONDUCT.md|.github/CODE_OF_CONDUCT.md|CODE_OF_CONDUCT.adoc|3-practice/CODE_OF_CONDUCT.adoc", "CHANGELOG.adoc|CHANGELOG.md", "0-AI-MANIFEST.a2ml|0-AI-MANIFEST.deed", ".machine_readable/descriptiles/|machine-readable/descriptiles/", ".machine_readable/rsr-profile.a2ml|machine-readable/rsr-profile.a2ml", ".well-known/|www/.well-known/", ".gitignore", "Justfile"] [gates] @@ -71,9 +74,12 @@ paths = ["README.adoc", "EXPLAINME.adoc|docs/EXPLAINME.adoc", "LICENSE", "SECURI "abi.ipkg" = "abi" "src/interface/abi/|src/interface/Abi/" = "abi" "src/interface/generated/" = "abi" -"verification/proofs/" = "formal-proofs" +# Both layouts carry proof sources. The checker requires a nonempty prover +# source file: an empty directory or a README is not a proof capability. +"verification/proofs/|proofs/agda/" = "formal-proofs" ".github/workflows/e2e.yml" = "api-service" -"build/container/|container/" = "container" +# A root Containerfile is already an accepted recipe. Requiring an additional +# container/ directory creates empty scaffolding without another obligation. "build/container/Containerfile|container/Containerfile|Containerfile" = "container" # Guix is primary; Nix is fallback-only (criterion 1.2.1). As two separate rows # this demanded BOTH, so every repo that correctly retired Nix failed the gate. diff --git a/bots/rhodibot/src/canon/profile.rs b/bots/rhodibot/src/canon/profile.rs index 6786e7cf..f737dd0e 100644 --- a/bots/rhodibot/src/canon/profile.rs +++ b/bots/rhodibot/src/canon/profile.rs @@ -491,7 +491,7 @@ version = \"2.0.4\" #[test] fn the_vendored_gate_table_has_the_expected_shape() { let table = gates(); - assert_eq!(table.version(), "0.3.0"); + assert_eq!(table.version(), "0.3.1"); assert_eq!( table.known_count(), 28, diff --git a/docs/BRANCH-PROTECTION-SETUP.adoc b/docs/BRANCH-PROTECTION-SETUP.adoc index eac93ab7..9d260755 100644 --- a/docs/BRANCH-PROTECTION-SETUP.adoc +++ b/docs/BRANCH-PROTECTION-SETUP.adoc @@ -74,24 +74,20 @@ Then add the status checks in *Settings > Branches* as described above. === Local Pre-Push Hook -A local hook mirrors the CI gate so developers catch critical findings -before pushing. Install it from `+gitbot-fleet/hooks/pre-push-gate.sh+`: +A local static-analysis hook catches critical findings before pushing. +Use the installer from a reviewed fleet checkout; it preserves existing hooks +and resolves configured hooks paths and linked worktrees through Git: [source,bash] ---- -# Symlink (recommended — always picks up updates) -ln -sf ~/Documents/hyperpolymath-repos/gitbot-fleet/hooks/pre-push-gate.sh \ - .git/hooks/pre-push - -# Or copy -cp ~/Documents/hyperpolymath-repos/gitbot-fleet/hooks/pre-push-gate.sh \ - .git/hooks/pre-push -chmod +x .git/hooks/pre-push +bash /path/to/gitbot-fleet/scripts/install-pre-push-gate.sh /path/to/consumer ---- -The hook gracefully degrades: if neither panic-attack nor hypatia is -installed locally, it prints a notice and allows the push (CI will catch -it). +The hook blocks if no scanner completes, a configured scanner is unavailable, +a report is malformed, a scanner fails or times out, or a critical finding is +reported. Static scanning is not CI execution parity. Configure the consumer's +owned execution adapter separately. See xref:PRE-PUSH-GATE.adoc[the gate's +dependencies, configuration, installation and validation limits]. ''''' diff --git a/scripts/install-pre-push-gate.sh b/scripts/install-pre-push-gate.sh index 90d91d94..1ca7e98a 100644 --- a/scripts/install-pre-push-gate.sh +++ b/scripts/install-pre-push-gate.sh @@ -5,7 +5,10 @@ set -euo pipefail repo=${1:?usage: install-pre-push-gate.sh REPOSITORY} source_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) repo=$(git -C "$repo" rev-parse --show-toplevel) -hook=$(git -C "$repo" rev-parse --path-format=absolute --git-path hooks/pre-push) +# Resolve the directory through Git, but preserve the final hook entry itself: +# resolving hooks/pre-push can follow a dangling symlink to an absent target. +hook_dir=$(git -C "$repo" rev-parse --path-format=absolute --git-path hooks) +hook="$hook_dir/pre-push" source_hook="$source_root/hooks/pre-push-gate.sh" if [[ -e "$hook" || -L "$hook" ]]; then if cmp -s "$source_hook" "$hook" && [[ -x "$hook" ]]; then