#!/usr/bin/env bash # Two jobs, in this order (the genomics-agent pattern): # 1. ADR-0014 security gate — runs on EVERY push. Blocks on findings. # 2. GitHub mirror sync — only on a PROD deploy (`main` to `origin`). Warns, never blocks. # The gate runs first, so a blocked deploy can never have already updated the mirror. # # --- 1. ADR-0014 pre-push security gate ---------------------------------------------------- # # `origin` and `hf-dev` ARE HuggingFace Spaces — a push builds a live Space — so there is # no CI merge gate in front of them. This hook is the enforced check: it runs the shared # scan (scripts/security_scan.sh) before anything leaves the machine. # # It scans the commit being PUSHED, not the working tree it happens to run from. The # sibling hooks scan the working tree, and on 2026-09-21 that went wrong in the # orchestrator: pushed from a checkout parked on another lane's branch, the scan audited # THAT branch's requirements.txt instead of the commit being shipped. Here each pushed sha # is checked out into a throwaway worktree and scanned there; reports (and the cached # audit venv) are written to this checkout's security/ so they outlive it. # # Install with: make install-hooks # Bypass (e.g. a docs-only push, knowingly): git push --no-verify # Set SECURITY_SCAN_STRICT=1 to fail on skipped stages. set -euo pipefail # The mirror push below sets this, so that nested push is neither rescanned nor re-mirrored. [[ -n "${SECURITY_SCAN_DONE:-}" ]] && exit 0 remote_name="${1:-}" remote_url="${2:-}" mirror_remote="${MIRROR_REMOTE:-github}" dev_remote="${DEV_REMOTE:-hf-dev}" repo_root="$(git rev-parse --show-toplevel)" # stdin: " " per ref being pushed. Git sends it # once and both jobs need it, so it is read here and replayed from the variable. push_lines="$(cat)" shas=() while read -r _lref lsha _rref _rsha; do [[ -n "${lsha:-}" ]] || continue case "$lsha" in *[!0]*) ;; *) continue ;; esac # all-zero = a delete; nothing to scan [[ " ${shas[*]-} " == *" $lsha "* ]] || shas+=("$lsha") done <<< "$push_lines" [[ ${#shas[@]} -gt 0 ]] || exit 0 # Sweep leaks from earlier interrupted pushes. Named (not `mktemp -d`) so a leak is # identifiable, and keyed on LIVENESS rather than age: each scan dir records its hook's PID # and its scan child's, so a dir whose hook is gone is swept at once while a CONCURRENT # push's dir is never touched. A dir with no .hookpid predates this and is by definition a # leak. (2026-09-23: a leaked worktree was found with `git worktree add`'s inner # `git reset --hard` still sleeping after 2 days 7 hours, in a directory macOS had reaped.) for stale in "${TMPDIR:-/tmp}"/pre-push-scan.*; do [[ -d "$stale" ]] || continue stale_pid="$(cat "$stale/.hookpid" 2>/dev/null || true)" if [[ -z "$stale_pid" ]] || ! kill -0 "$stale_pid" 2>/dev/null; then stale_scan="$(cat "$stale/.scanpid" 2>/dev/null || true)" if [[ -n "$stale_scan" ]] && kill -0 "$stale_scan" 2>/dev/null; then kill -TERM "-$stale_scan" 2>/dev/null || kill -TERM "$stale_scan" 2>/dev/null || true sleep 1 if kill -0 "$stale_scan" 2>/dev/null; then kill -KILL "-$stale_scan" 2>/dev/null || kill -KILL "$stale_scan" 2>/dev/null || true fi fi for wt in "$stale"/wt-*; do [[ -d "$wt" ]] && git worktree remove --force "$wt" >/dev/null 2>&1 done rm -rf "$stale" echo "[pre-push] swept a leaked scan directory from an interrupted push: $(basename "$stale")" fi done git worktree prune >/dev/null 2>&1 || true tmp="$(mktemp -d "${TMPDIR:-/tmp}/pre-push-scan.XXXXXX")" echo "$$" > "$tmp/.hookpid" scan_pid="" cleanup() { # Kill the scan BEFORE its directory disappears: deleting the tree under a live child is # how a gitleaks or `git reset --hard` ends up running in a path that no longer exists. if [[ -n "$scan_pid" ]] && kill -0 "$scan_pid" 2>/dev/null; then # Negative pid = the whole process group. run_scan starts the job with `set -m` so it # gets its own group; signalling the group takes gitleaks / pip-audit / bandit down with # the shell that launched them, instead of reparenting them to init. kill -TERM "-$scan_pid" 2>/dev/null || kill -TERM "$scan_pid" 2>/dev/null || true wait "$scan_pid" 2>/dev/null || true fi for wt in "$tmp"/wt-*; do [[ -d "$wt" ]] && git worktree remove --force "$wt" >/dev/null 2>&1; done git worktree prune >/dev/null 2>&1 || true rm -rf "$tmp" } # EXIT alone is not enough: a non-interactive bash killed by an UNTRAPPED signal dies # without running its EXIT trap, so Ctrl-C, a closed terminal or a terminated agent session # left the worktree registered and its directory behind. `trap - EXIT` stops a double run. # Nothing in-process survives SIGKILL — that case is what the sweep above is for. on_signal() { cleanup trap - EXIT echo "[pre-push] interrupted — scan worktree cleaned up; push aborted." >&2 exit 130 } trap cleanup EXIT trap on_signal INT TERM HUP # Run the scan as a background job and `wait`: bash defers a trap handler until the running # FOREGROUND command returns, so a Ctrl-C during a multi-minute scan would not be acted on # until the scan finished. `wait` IS interruptible, so cleanup runs at once. run_scan() { set -m # give the job its own process group, so cleanup can signal the tree "$@" & scan_pid=$! set +m echo "$scan_pid" > "$tmp/.scanpid" local rc=0 wait "$scan_pid" || rc=$? scan_pid="" rm -f "$tmp/.scanpid" return "$rc" } for sha in "${shas[@]}"; do short="${sha:0:7}" # Branches that predate the scan have no script — skip them cleanly rather than block. if ! git cat-file -e "$sha:scripts/security_scan.sh" 2>/dev/null; then echo "[pre-push] $short has no scripts/security_scan.sh — skipping scan for it." continue fi echo "[pre-push] running ADR-0014 security scan on the pushed commit $short (git push --no-verify to skip)…" wt="$tmp/wt-$short" git worktree add --detach --quiet "$wt" "$sha" # Git exports GIT_DIR & co. into hooks; left set, the scan's own git calls (bandit's # target discovery, gitleaks) would read the wrong checkout. scan_job() { env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX \ SECURITY_SCAN_OUT="$repo_root/security" bash "$wt/scripts/security_scan.sh" } if run_scan scan_job; then echo "[pre-push] $short: security scan clean." else echo "[pre-push] $short: security scan reported findings — push blocked." >&2 echo "[pre-push] triage the reports in security/, or 'git push --no-verify' to override." >&2 exit 1 fi done echo "[pre-push] security scan clean — proceeding." # --- 2. GitHub mirror sync ----------------------------------------------------------------- # `origin` IS the prod Space and `github` is a source mirror that only runs CI; nothing links # them, so the mirror fell behind prod by hand-sync on 2026-10-01 more than once. A stale mirror # means CI runs a SUBSET of production, which reads as a pass. This syncs the mirror's `main` # at the only moment git offers: when a prod deploy is about to go out. # # Two honest limitations (as in pdac-genomics-agent): # 1. The mirror is pushed FIRST: git has no post-push hook. If the `origin` push then fails, # `github` is briefly AHEAD of prod. That self-corrects on the next deploy, and CI running a # superset of production is the milder error. # 2. A mirror failure WARNS; it does not block. The mirror is CI; `origin` is production. It is # pushed without force, so a mirror that has moved on (another lane synced it) is left alone. # # Only `main` on `origin` is mirrored. `git push hf-dev dev:main` also targets refs/heads/main on # a huggingface.co URL, so the dev Space is excluded by name AND by URL first — mirroring it # would put dev code on the mirror's `main`, where CI reads it as production. case "$remote_name$remote_url" in "$dev_remote"*|*-dev|*-dev.git|*-dev/) exit 0 ;; esac case "$remote_name$remote_url" in origin*|*huggingface.co/spaces/anne-voigt/pdac-knowledge-agent) ;; *) exit 0 ;; esac main_sha="" while read -r _lref lsha rref _rsha; do [[ "${rref:-}" == "refs/heads/main" ]] || continue case "${lsha:-}" in *[!0]*) main_sha="$lsha" ;; esac # all-zero = a DELETE; never mirror it done <<< "$push_lines" [[ -n "$main_sha" ]] || exit 0 # nothing headed for main: not a deploy if ! git remote get-url "$mirror_remote" >/dev/null 2>&1; then echo "[pre-push] no '$mirror_remote' remote — the GitHub mirror will fall behind this deploy." >&2 echo "[pre-push] git remote add $mirror_remote https://github.com/Anne-Voigt/pdac-knowledge-agent.git" >&2 exit 0 fi echo "[pre-push] mirroring ${main_sha:0:7} to '$mirror_remote' main so the mirror matches prod…" if SECURITY_SCAN_DONE=1 git push --quiet "$mirror_remote" "$main_sha:refs/heads/main"; then echo "[pre-push] mirror in sync — proceeding with the deploy." else echo "[pre-push] WARNING: mirror push failed (often: it already moved on). The deploy proceeds." >&2 echo "[pre-push] Check it after the deploy: git fetch $mirror_remote && git log --oneline -1 $mirror_remote/main" >&2 fi exit 0