99#
1010# Uses Brigade's embedded content guard. CONTENT_GUARD_DIR is an explicit
1111# compatibility override for an older standalone checkout.
12+ #
13+ # Two scans run per policy:
14+ # 1. tracked tip - working tree files (brigade scrub / content_guard scan)
15+ # 2. push history - content INTRODUCED by the commits being pushed
16+ # (closes the forward-scrub gap: a clean tip can still
17+ # sit on top of commits that leak in their diffs)
18+ #
19+ # For brand-new branches (remote SHA all-zero) the history scan derives the
20+ # exclusion set from `git ls-remote <url>` rather than local tracking refs,
21+ # which can be stale. Only commits not reachable from any advertised ref are
22+ # scanned, batched into one guard history process per policy via --revs-stdin.
1223set -euo pipefail
1324
1425REPO_ROOT=" $( git rev-parse --show-toplevel) "
26+ cd " $REPO_ROOT "
27+
28+ # Git invokes pre-push as: pre-push <remote-name> <url>. The URL is the
29+ # destination actually being pushed to; it is the only source of truth for
30+ # what the remote currently advertises. Local remote-tracking refs
31+ # (refs/remotes/<name>/*) are NOT used to derive the exclusion set, because
32+ # they can be stale: a tracking ref can point at a commit the remote no
33+ # longer holds, or be absent for a commit the remote already holds. Using
34+ # them would over- or under-exclude and either miss a real leak or block on
35+ # already-public content. ${2:-} keeps `set -u` happy when a caller forgets
36+ # the URL (git always passes both).
37+ REMOTE_URL=" ${2:- } "
38+
1539rc=0
40+ PLUMBING_ERR=0
41+ STDIN_REFS=" $( cat) "
42+
1643if [[ -n " ${CONTENT_GUARD_DIR:- } " ]]; then
1744 POLICY=" ${CONTENT_GUARD_POLICY:- $CONTENT_GUARD_DIR / policies/ public-repo.json} "
1845 if [[ ! -d " $CONTENT_GUARD_DIR " ]]; then
@@ -24,32 +51,169 @@ if [[ -n "${CONTENT_GUARD_DIR:-}" ]]; then
2451 exit 1
2552 fi
2653 echo " pre-push: scanning $REPO_ROOT with explicit content-guard compatibility override"
27- PYTHONPATH=" $CONTENT_GUARD_DIR /src" python3 -m content_guard scan " $REPO_ROOT " --policy " $POLICY " || rc=$?
54+ RUN_TIP () {
55+ local pol=" $1 "
56+ PYTHONPATH=" $CONTENT_GUARD_DIR /src" python3 -m content_guard scan " $REPO_ROOT " --policy " $pol " || rc=$?
57+ }
58+ RUN_HISTORY () {
59+ local pol=" $1 "
60+ PYTHONPATH=" $CONTENT_GUARD_DIR /src" python3 -m content_guard.git_scan \
61+ --history --revs-stdin --policy " $pol " " ${ALLOW_FROM[@]+" ${ALLOW_FROM[@]} " } " || rc=$?
62+ }
63+ RUN_HISTORY_RANGE () {
64+ local range=" $1 "
65+ local pol=" $2 "
66+ PYTHONPATH=" $CONTENT_GUARD_DIR /src" python3 -m content_guard.git_scan \
67+ --history --range " $range " --policy " $pol " " ${ALLOW_FROM[@]+" ${ALLOW_FROM[@]} " } " || rc=$?
68+ }
2869else
2970 POLICY=" ${CONTENT_GUARD_POLICY:- public-repo} "
3071 if ! command -v brigade > /dev/null 2>&1 ; then
3172 echo " pre-push: brigade not found; reinstall brigade-cli to restore the embedded guard" >&2
3273 exit 1
3374 fi
3475 echo " pre-push: scanning $REPO_ROOT with Brigade's embedded guard"
35- brigade scrub --target " $REPO_ROOT " --policy " $POLICY " --no-receipt || rc=$?
76+ RUN_TIP () {
77+ local pol=" $1 "
78+ brigade scrub --target " $REPO_ROOT " --policy " $pol " --no-receipt || rc=$?
79+ }
80+ RUN_HISTORY () {
81+ local pol=" $1 "
82+ brigade guard git --history --revs-stdin --policy " $pol " " ${ALLOW_FROM[@]+" ${ALLOW_FROM[@]} " } " || rc=$?
83+ }
84+ RUN_HISTORY_RANGE () {
85+ local range=" $1 "
86+ local pol=" $2 "
87+ brigade guard git --history --range " $range " --policy " $pol " " ${ALLOW_FROM[@]+" ${ALLOW_FROM[@]} " } " || rc=$?
88+ }
89+ fi
90+
91+ # Optional private policy (a private identifier denylist kept out of public
92+ # repos). If present, the hook runs a second scan pass against it.
93+ EXTRA_POLICY=" ${CONTENT_GUARD_EXTRA_POLICY:- $HOME / .config/ content-guard/ internal.json} "
94+ ALLOW_FROM=()
95+ [[ -f " $EXTRA_POLICY " ]] && ALLOW_FROM=(--allow-values-from " $EXTRA_POLICY " )
96+
97+ # Track the worst scanner outcome across all scans: BLOCKED (rc=1, a real
98+ # leak verdict) vs SCANNER_ERR (rc>1, the scanner failed to run). Issue #82:
99+ # never mislabel a scanner/plumbing failure as found violations.
100+ BLOCKED=0
101+ SCANNER_ERR=0
102+ note_rc () {
103+ if (( rc > 1 )) ; then SCANNER_ERR=$rc ; elif (( rc == 1 )) ; then BLOCKED=1; fi
104+ rc=0
105+ }
106+
107+ ADVERTISED_REFS=" "
108+ REMOTE_ENUMERATED=0
109+
110+ prepare_new_branch_ranges () {
111+ local needs_remote=0
112+ local _lref lsha _rref rsha
113+ while read -r _lref lsha _rref rsha; do
114+ [[ -z " ${lsha:- } " || " $lsha " =~ ^0+$ ]] && continue
115+ if [[ " $rsha " =~ ^0+$ ]]; then
116+ needs_remote=1
117+ break
118+ fi
119+ done < <( printf ' %s\n' " $STDIN_REFS " )
120+ [[ " $needs_remote " -eq 1 ]] || return 0
121+
122+ if [[ -z " $REMOTE_URL " ]]; then
123+ echo " pre-push: [history] cannot enumerate remote refs (no destination URL)" >&2
124+ PLUMBING_ERR=1
125+ return
126+ fi
127+
128+ local advertised
129+ # Keep peeled annotated-tag lines (`refs/tags/<name>^{}`), which --refs
130+ # suppresses. The peeled commit can be present locally even when the tag
131+ # object is not, and it still marks that commit as already public.
132+ if ! advertised=" $( GIT_TERMINAL_PROMPT=0 git ls-remote " $REMOTE_URL " 2>&1 ) " ; then
133+ echo " pre-push: [history] failed to enumerate advertised refs for $REMOTE_URL " >&2
134+ while IFS= read -r line; do
135+ [[ -n " $line " ]] && echo " pre-push: $line " >&2
136+ done <<< " $advertised"
137+ PLUMBING_ERR=1
138+ return
139+ fi
140+ ADVERTISED_REFS=" $advertised "
141+ REMOTE_ENUMERATED=1
142+ }
143+
144+ scan_with () {
145+ local pol=" $1 "
146+ local new_revs new_count range
147+ RUN_TIP " $pol "
148+ note_rc
149+ while read -r _lref lsha _rref rsha; do
150+ [[ -z " ${lsha:- } " || " $lsha " =~ ^0+$ ]] && continue # branch deletion
151+ if [[ " $rsha " =~ ^0+$ ]]; then
152+ [[ " $REMOTE_ENUMERATED " -eq 1 ]] || continue
153+ if ! git rev-parse -q --verify " ${lsha} ^{commit}" > /dev/null; then
154+ echo " pre-push: [history] local commit $lsha does not resolve" >&2
155+ PLUMBING_ERR=1
156+ continue
157+ fi
158+ if ! new_revs=" $(
159+ printf ' %s\n' " $ADVERTISED_REFS " |
160+ awk ' NF { print "^" $1 }' |
161+ git rev-list --ignore-missing " $lsha " --stdin
162+ ) " ; then
163+ echo " pre-push: [history] failed to resolve local history for $lsha " >&2
164+ PLUMBING_ERR=1
165+ continue
166+ fi
167+ if [[ -z " $new_revs " ]]; then
168+ echo " pre-push: [history] no new commits to scan for $lsha "
169+ continue
170+ fi
171+ new_count=" $( printf ' %s\n' " $new_revs " | grep -c .) "
172+ echo " pre-push: [history] scanning introduced content in $new_count new commit(s) for ${_lref:- $lsha } "
173+ # Batch the exact new-commit set into ONE guard history process via
174+ # --revs-stdin. Revisions are data on stdin, not argv: no shell
175+ # splitting, no option injection, one process per policy regardless
176+ # of how many commits are new.
177+ RUN_HISTORY " $pol " <<< " $new_revs"
178+ note_rc
179+ else
180+ range=" $rsha ..$lsha "
181+ echo " pre-push: [history] scanning introduced content in $range "
182+ RUN_HISTORY_RANGE " $range " " $pol "
183+ note_rc
184+ fi
185+ done < <( printf ' %s\n' " $STDIN_REFS " )
186+ }
187+
188+ prepare_new_branch_ranges
189+ scan_with " $POLICY "
190+ if [[ -n " $EXTRA_POLICY " && -f " $EXTRA_POLICY " ]]; then
191+ scan_with " $EXTRA_POLICY "
36192fi
37193
38- if [[ " $rc " -eq 0 ]]; then
39- exit 0
194+ if (( SCANNER_ERR > 0 )) ; then
195+ # Any non-1 exit code is the scanner failing to run (missing deps, bad
196+ # policy, crash, or a plumbing failure surfaced as exit 2), not a leak
197+ # verdict. Do not mislabel it as found violations (issue #82).
198+ echo >&2
199+ echo " pre-push: content-guard failed to run (exit code $SCANNER_ERR ); this is a scanner error, not a leak verdict." >&2
200+ echo " pre-push: re-run it directly to see the error, then push again once the scanner works." >&2
201+ exit 1
202+ fi
203+
204+ if (( PLUMBING_ERR > 0 )) ; then
205+ echo >&2
206+ echo " pre-push: remote history could not be determined; this is a git plumbing error, not a leak verdict." >&2
207+ echo " pre-push: fix the remote or local Git state, then push again." >&2
208+ exit 1
40209fi
41210
42- if [[ " $rc " -eq 1 ]]; then
211+ if [[ " $BLOCKED " -ne 0 ]]; then
43212 echo >&2
44213 echo " pre-push: BLOCKED. content-guard found violations." >&2
45214 echo " pre-push: fix the leak, or add an inline allow-tag on the offending line:" >&2
46215 echo " pre-push: <!-- content-guard: allow <rule-id> -->" >&2
47216 exit 1
48217fi
49218
50- # Any other exit code is the scanner failing to run (missing deps, bad policy,
51- # crash), not a leak verdict. Do not mislabel it as found violations.
52- echo >&2
53- echo " pre-push: content-guard failed to run (exit code $rc ); this is a scanner error, not a leak verdict." >&2
54- echo " pre-push: re-run it directly to see the error, then push again once the scanner works." >&2
55- exit 1
219+ exit 0
0 commit comments