File size: 9,269 Bytes
377417d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
#!/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: "<local ref> <local sha> <remote ref> <remote sha>" 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