@chrono-meta/fh-gate 1.4.72 → 1.4.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/.public-surface-patterns.defaults +44 -0
- package/.claude/rules/fh_4axis_gate.md +207 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +26 -2
- package/CATALOG.md +31 -0
- package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
- package/package.json +21 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
- package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
- package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
- package/scripts/chamber_candidate_collect.sh +223 -0
- package/scripts/degrade_direction_scan.sh +222 -0
- package/scripts/fh_session_load.sh +202 -0
- package/scripts/gate_pathspec_check.sh +166 -0
- package/scripts/prepush_guard_check.sh +374 -0
- package/scripts/psa_scan_lib.sh +153 -0
- package/scripts/public_surface_scan_files.sh +157 -0
- package/scripts/selfcheck.sh +16 -0
- package/scripts/session_close_check.sh +171 -0
- package/scripts/test_degrade_scan_shell_probes.sh +185 -0
- package/scripts/test_prepush_stdin_integrity.sh +119 -0
- package/scripts/universal_guard_check.sh +280 -0
- package/templates/.claude/rules/mcp_tool_gating.md +157 -0
- package/templates/.git-hooks/pre-commit +848 -0
- package/templates/.git-hooks/pre-push +585 -0
- package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
- package/templates/degrade_direction_scan.sh +222 -0
- package/templates/predelete_check.sh +72 -0
- package/templates/regression_guard.sh +563 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# FH Destructive-Op Gate — Pre-Push Hook
|
|
3
|
+
#
|
|
4
|
+
# Mechanically enforces the Destructive-Op Gate (CLAUDE.md) for the *git-side*
|
|
5
|
+
# irreversible surfaces the **pre-commit** hook cannot see, because they happen at
|
|
6
|
+
# push time, not commit time:
|
|
7
|
+
# • remote branch deletion (git push origin --delete X / git push origin :X)
|
|
8
|
+
# • tag / notes ref deletion (git push origin :refs/tags/vX)
|
|
9
|
+
# • FORCE / non-fast-forward push (history rewrite — git push -f)
|
|
10
|
+
# • implicit deletes from git push --mirror / --prune
|
|
11
|
+
#
|
|
12
|
+
# What it is and is NOT (honest scope):
|
|
13
|
+
# • It closes the **honest-weak-model** gap: an agent that simply *forgot* the prose
|
|
14
|
+
# gate is now stopped by a mechanical block. That is the real, common win.
|
|
15
|
+
# • It does NOT close the **injected/adversarial** gap: an agent under instruction can
|
|
16
|
+
# set DESTRUCTIVE_OP_OK=1 or --no-verify (any client-side hook is bypassable, and
|
|
17
|
+
# this hook is readable). The actual mechanical floor for the adversarial case is
|
|
18
|
+
# **server-side branch protection** (GitHub "Restrict deletions" / "Restrict force
|
|
19
|
+
# pushes"). This hook is the honest-model floor; branch protection is the hard floor.
|
|
20
|
+
# • It covers only git pushes FROM a hook-installed repo. Non-git irreversible ops
|
|
21
|
+
# (separate-repo `gh repo create --public`, visibility flip, `npm publish`) are
|
|
22
|
+
# genuinely un-hookable → prose + templates/PRE-PUBLISH-CHECKLIST.md.
|
|
23
|
+
#
|
|
24
|
+
# FH-internal infra: activated only via `core.hooksPath=templates/.git-hooks`, not
|
|
25
|
+
# installed into field projects.
|
|
26
|
+
#
|
|
27
|
+
# Degrade direction: irreversible surface → fail-CLOSED. Missing tooling / unresolvable
|
|
28
|
+
# base / unfetched remote tip → BLOCK (never silently allow). Verified: no fail-open path.
|
|
29
|
+
#
|
|
30
|
+
# Override (explicit, logged — mirrors the pre-commit PUBLIC_SURFACE_OK channel):
|
|
31
|
+
# DESTRUCTIVE_OP_OK=1 git push … ← use AFTER enumerate + recover are done.
|
|
32
|
+
#
|
|
33
|
+
# Install (one-time, from repo root):
|
|
34
|
+
# git config core.hooksPath templates/.git-hooks
|
|
35
|
+
# chmod +x templates/.git-hooks/pre-push
|
|
36
|
+
#
|
|
37
|
+
# git passes on stdin, one line per ref: <local_ref> <local_sha> <remote_ref> <remote_sha>
|
|
38
|
+
|
|
39
|
+
set -uo pipefail
|
|
40
|
+
|
|
41
|
+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
|
42
|
+
[ -z "$REPO_ROOT" ] && { echo " ❌ pre-push: not in a git repo — fail-closed (block)"; exit 1; }
|
|
43
|
+
ZERO="0000000000000000000000000000000000000000"
|
|
44
|
+
PUSH_REMOTE="${1:-origin}" # git passes the remote NAME as $1; ranges are scoped to it (see below)
|
|
45
|
+
BASE="${FH_DESTRUCTIVE_BASE:-origin/main}"
|
|
46
|
+
# All ancestry/reachability checks below must ignore local `git replace`/graft objects — a graft can
|
|
47
|
+
# falsify merge-base/rev-list/ls-tree to make a divergent force look fast-forward or an unmerged branch
|
|
48
|
+
# look SAFE (cross-family audit 2026-06-27, reproduced). Export here so every git call in the hook honors it.
|
|
49
|
+
export GIT_NO_REPLACE_OBJECTS=1
|
|
50
|
+
|
|
51
|
+
DEL_BRANCHES="" # space-separated "<tip-sha>|refs/heads/X" pairs — SHA first so a '|' that is LEGAL in a
|
|
52
|
+
# ref name (git check-ref-format allows it) cannot corrupt the split (bash-3.2 safe).
|
|
53
|
+
DEL_OTHER="" # refs/tags/* refs/notes/* etc. being deleted
|
|
54
|
+
FORCED_REFS=""
|
|
55
|
+
UNCLASSIFIED="" # force-check impossible (remote tip not fetched)
|
|
56
|
+
DIRECT_MAIN="" # non-delete update pushed straight at the integration branch (PR-only policy)
|
|
57
|
+
SEP=$'\n' # ranges are newline-separated and evaluated ONE REF AT A TIME: concatenating
|
|
58
|
+
# them into one arg string let `--not` from ref A flip polarity for ref B, so a
|
|
59
|
+
# multi-ref push could return an empty or wrong commit set (R6 audit 2026-07-26).
|
|
60
|
+
PUSH_RANGES="" # rev-list args for exactly the commits this push would publish (R5 audit 2026-07-26)
|
|
61
|
+
|
|
62
|
+
# --- stacked-branch advisory (ADVISORY — never changes the verdict) --------------------------
|
|
63
|
+
# Warns when the branch being pushed carries commits that already live on ANOTHER unmerged remote
|
|
64
|
+
# branch. That is the signature of a branch cut while standing on a feature branch instead of on
|
|
65
|
+
# the integration branch.
|
|
66
|
+
#
|
|
67
|
+
# Measured origin (2026-07-27, a field harness, PRs #38/#39): a branch was cut off a feature branch
|
|
68
|
+
# by accident, so the child PR carried the parent's three commits (parent PR = 3 commits, measured).
|
|
69
|
+
# From there BOTH available routes cost something, and both bills arrive at parent-merge time:
|
|
70
|
+
# (a) leave the child based on the integration branch → its diff shows the parent's changes too,
|
|
71
|
+
# and once the parent is squash-merged the SHAs no longer match, so the child goes CONFLICTING;
|
|
72
|
+
# (b) retarget the child onto the parent branch to clean the diff → merging the parent with
|
|
73
|
+
# `--delete-branch` deletes that base and GitHub **CLOSES** the child rather than retargeting it.
|
|
74
|
+
# The observed run took (b) and ended CLOSED + CONFLICTING (`state=CLOSED`, `mergeable=CONFLICTING`).
|
|
75
|
+
# In both routes the tempting recovery is `git push -f`, which walks straight into the irreversible
|
|
76
|
+
# surface THIS HOOK exists to guard. (Clean recovery: re-cut from the integration branch and
|
|
77
|
+
# cherry-pick — no history rewrite.)
|
|
78
|
+
#
|
|
79
|
+
# Why it is worth a line in a destructive-op hook: the mistake happens at branch-cut time but only
|
|
80
|
+
# bites at parent-merge time, so the two are hard to connect — and its natural "fix" is a history
|
|
81
|
+
# rewrite. Surfacing it at push time removes the pressure before it reaches the force-push path.
|
|
82
|
+
#
|
|
83
|
+
# ADVISORY on purpose (Surface-Class Degrade Invariant, reversible half): intentional stacked PRs
|
|
84
|
+
# are a legitimate workflow, so blocking would be pure over-blocking — and over-blocking trains
|
|
85
|
+
# `--no-verify`, which disarms the IRREVERSIBLE guards in this same hook. What this closes is
|
|
86
|
+
# silence, not permission.
|
|
87
|
+
fh_stacked_branch_advisory() {
|
|
88
|
+
_sb_ref="$1"; _sb_sha="$2"
|
|
89
|
+
case "$_sb_ref" in refs/heads/*) ;; *) return 0 ;; esac
|
|
90
|
+
_sb_self="${_sb_ref#refs/heads/}"; _sb_hit=""
|
|
91
|
+
|
|
92
|
+
# Single baseline — this hook already resolved one at the top (BASE). Wave-1 caught the first
|
|
93
|
+
# draft inventing a 4-candidate fallback chain while its own comment claimed it reused BASE:
|
|
94
|
+
# comment and code disagreed, and the extra candidates existed nowhere else in the file.
|
|
95
|
+
if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null 2>&1; then
|
|
96
|
+
# 부재는 통과가 아니다 — a silent `return` here is indistinguishable from "scanned, nothing
|
|
97
|
+
# found". Say that the scan did not happen. (Advisory, so it still does not block.)
|
|
98
|
+
printf ' ℹ️ [fh-advisory:stacked-branch] SKIPPED (all refs) — base %s does not resolve (FH_DESTRUCTIVE_BASE or default origin/main; shallow clone / unfetched remote). Not scanned. Fix: git fetch origin, or set FH_DESTRUCTIVE_BASE.\n' "$BASE" >&2
|
|
99
|
+
return 0
|
|
100
|
+
fi
|
|
101
|
+
# Orphan / unrelated history has no merge-base, so `BASE..HEAD` would enumerate the branch's
|
|
102
|
+
# ENTIRE history — none of which is "cut off the wrong base". Different situation, not this one.
|
|
103
|
+
if ! git merge-base "$BASE" "$_sb_sha" >/dev/null 2>&1; then
|
|
104
|
+
return 0
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
# Ref enumeration uses for-each-ref with an explicit TAB-delimited format — never the human
|
|
108
|
+
# `git branch` output. Cross-family review (codex, 2026-07-27) named three defects that all
|
|
109
|
+
# traced to that one choice: `origin/HEAD -> origin/main` parses as ref="HEAD", branch names
|
|
110
|
+
# containing spaces get truncated (so self-exclusion can fail), and `* `/`+ ` prefixes leak in.
|
|
111
|
+
# for-each-ref emits refs, not a display listing, so the input grammar is actually what the
|
|
112
|
+
# parser assumes.
|
|
113
|
+
#
|
|
114
|
+
# Self-exclusion is by EXACT name comparison, never by interpolating the branch name into a
|
|
115
|
+
# regex. Wave-1 reproduced that bug: a branch named `feat/a.b` built the pattern `/feat/a.b$`,
|
|
116
|
+
# whose `.` also matched a genuinely different branch `origin/feat/aXb` — grep -v dropped the
|
|
117
|
+
# REAL hit and the advisory silently no-opped on a true positive.
|
|
118
|
+
#
|
|
119
|
+
# The integration branch is excluded by its RESOLVED name (from BASE), not by hard-coded
|
|
120
|
+
# `main`/`master` (cross-family MED-2): hard-coding hides a genuine stack built on a local
|
|
121
|
+
# branch that merely happens to be called `main` in a repo whose base is something else.
|
|
122
|
+
_sb_base_name="${BASE##*/}"
|
|
123
|
+
#
|
|
124
|
+
# ⚠️ Each commit in the range is checked, NOT just the tip. Wave-2 briefly rewrote this to test
|
|
125
|
+
# `$_sb_sha` alone and the anchors caught it immediately: a child branch that adds its own commit
|
|
126
|
+
# has a tip nobody else carries — the parent's commits are the evidence, and they sit BELOW the
|
|
127
|
+
# tip. Bound: newest 50, stated in the message (a bounded best-effort advisory, not a proof).
|
|
128
|
+
_sb_others=""
|
|
129
|
+
for _sb_c in $(git rev-list "${BASE}..${_sb_sha}" 2>/dev/null | head -50); do
|
|
130
|
+
_sb_others=$(
|
|
131
|
+
{ git for-each-ref --contains "$_sb_c" --format='remote %(refname:short)' refs/remotes 2>/dev/null
|
|
132
|
+
# Local refs too: the mistake is made BEFORE the parent is ever pushed, which is the most
|
|
133
|
+
# likely moment for it. A remote-only check structurally misses that case (Wave-1 A).
|
|
134
|
+
git for-each-ref --contains "$_sb_c" --format='local %(refname:short)' refs/heads 2>/dev/null
|
|
135
|
+
} | awk -F'\t' -v self="$_sb_self" -v basename="$_sb_base_name" -v base="$BASE" '
|
|
136
|
+
{ kind=$1; ref=$2
|
|
137
|
+
name=ref
|
|
138
|
+
if (kind == "remote") sub(/^[^\/]*\//, "", name) # drop the remote segment only
|
|
139
|
+
if (name == self || name == "HEAD" || name == basename) next
|
|
140
|
+
if (ref == base) next
|
|
141
|
+
print kind " " ref }' | head -3
|
|
142
|
+
)
|
|
143
|
+
[ -n "$_sb_others" ] && { _sb_hit="$_sb_c"; break; }
|
|
144
|
+
done
|
|
145
|
+
[ -n "$_sb_others" ] || return 0
|
|
146
|
+
|
|
147
|
+
printf '\n ⚠️ [fh-advisory:stacked-branch] %s carries commit(s) that already live on another branch.\n' "$_sb_self" >&2
|
|
148
|
+
printf ' shared commit example: %s\n' "$(git log -1 --format='%h %s' "$_sb_hit" 2>/dev/null | cut -c1-72)" >&2
|
|
149
|
+
printf ' also on:\n' >&2
|
|
150
|
+
printf '%s\n' "$_sb_others" | sed 's/^/ - /' >&2
|
|
151
|
+
printf ' Intentional stack? Ignore this. Otherwise the branch was cut off a feature branch\n' >&2
|
|
152
|
+
printf ' instead of %s — re-cut from %s and cherry-pick BEFORE opening the PR.\n' "$BASE" "$BASE" >&2
|
|
153
|
+
printf ' Merging the parent with --squash --delete-branch CLOSES the child PR rather than\n' >&2
|
|
154
|
+
printf ' retargeting it, and `git push -f` to recover is the surface this hook guards.\n' >&2
|
|
155
|
+
printf ' (Advisory only — does not block. Scanned the NEWEST 50 commits of %s..%s.)\n\n' "$BASE" "$_sb_self" >&2
|
|
156
|
+
return 0
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
while read -r local_ref local_sha remote_ref remote_sha; do
|
|
160
|
+
[ -z "${remote_ref:-}" ] && continue
|
|
161
|
+
# Advisory first — it can never alter the verdict, and it must be visible even when a guard
|
|
162
|
+
# below blocks (the two findings are independent and the operator wants both at once).
|
|
163
|
+
[ "$local_sha" = "$ZERO" ] || fh_stacked_branch_advisory "$local_ref" "$local_sha"
|
|
164
|
+
if [ "$local_sha" = "$ZERO" ]; then
|
|
165
|
+
# local side zero → this refspec DELETES remote_ref
|
|
166
|
+
case "$remote_ref" in
|
|
167
|
+
refs/heads/*) DEL_BRANCHES="$DEL_BRANCHES ${remote_sha}|${remote_ref}" ;;
|
|
168
|
+
*) DEL_OTHER="$DEL_OTHER $remote_ref" ;;
|
|
169
|
+
esac
|
|
170
|
+
elif [ "${remote_sha:-$ZERO}" != "$ZERO" ]; then
|
|
171
|
+
# updating an existing remote ref.
|
|
172
|
+
case "$remote_ref" in
|
|
173
|
+
refs/tags/*)
|
|
174
|
+
# Any update to an EXISTING tag is a forced move of a published/external anchor — destructive
|
|
175
|
+
# regardless of ancestry (a descendant retag passes the merge-base FF test but is still a force).
|
|
176
|
+
FORCED_REFS="$FORCED_REFS $remote_ref" ;;
|
|
177
|
+
*)
|
|
178
|
+
# Branch update: fast-forward iff remote_sha is an ancestor of local_sha. Confirm remote_sha is
|
|
179
|
+
# present locally first — if not, cannot classify (fail-closed, say so rather than mislabel FORCE).
|
|
180
|
+
if ! git cat-file -e "${remote_sha}^{commit}" 2>/dev/null; then
|
|
181
|
+
UNCLASSIFIED="$UNCLASSIFIED $remote_ref"
|
|
182
|
+
elif ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
|
|
183
|
+
FORCED_REFS="$FORCED_REFS $remote_ref"
|
|
184
|
+
fi ;;
|
|
185
|
+
esac
|
|
186
|
+
fi
|
|
187
|
+
# PR-only policy: a non-delete update aimed straight at the integration branch.
|
|
188
|
+
# Detected INSIDE this loop because this is the only place git's ref list is readable.
|
|
189
|
+
if [ "$local_sha" != "$ZERO" ]; then
|
|
190
|
+
case "$remote_ref" in
|
|
191
|
+
refs/heads/main|refs/heads/master) DIRECT_MAIN="$DIRECT_MAIN $remote_ref" ;;
|
|
192
|
+
esac
|
|
193
|
+
# The EXACT set of commits this push publishes. Previously the load-bearing check guessed with
|
|
194
|
+
# HEAD~1..HEAD, which missed a multi-commit first push whose gate edit was not the tip (R5 audit).
|
|
195
|
+
# New branch (remote_sha ZERO) → everything not already on some remote, not the whole history.
|
|
196
|
+
if [ "${remote_sha:-$ZERO}" = "$ZERO" ]; then
|
|
197
|
+
# Scope the exclusion to the remote being pushed TO. `--not --remotes` excludes commits
|
|
198
|
+
# reachable from ANY remote, so a new branch whose commits already sit on a DIFFERENT remote
|
|
199
|
+
# (a fork, a mirror, a company GHE alongside the public origin) produced an EMPTY range —
|
|
200
|
+
# nothing scanned, clean line printed, and the push published them here for the first time
|
|
201
|
+
# (R7 audit, 2026-07-26). $1 is the remote name git passes to this hook.
|
|
202
|
+
PUSH_RANGES="${PUSH_RANGES}${PUSH_RANGES:+$SEP}$local_sha --not --remotes=${PUSH_REMOTE}"
|
|
203
|
+
else
|
|
204
|
+
PUSH_RANGES="${PUSH_RANGES}${PUSH_RANGES:+$SEP}${remote_sha}..${local_sha}"
|
|
205
|
+
fi
|
|
206
|
+
fi
|
|
207
|
+
# remote_sha == ZERO → creating a new branch (not destructive) → ignore
|
|
208
|
+
done
|
|
209
|
+
|
|
210
|
+
# ── Confidentiality at the PUBLISH boundary — instrument completeness + content ───
|
|
211
|
+
# Pattern loading and matching come from scripts/psa_scan_lib.sh, the single implementation shared
|
|
212
|
+
# with pre-commit and the publish scanner. Every confidentiality defect found in the 2026-07-26
|
|
213
|
+
# cross-family audit was a divergence between three near-duplicate copies of that logic, so the
|
|
214
|
+
# copies were removed. What stays HERE is the part that genuinely differs by surface: the degrade
|
|
215
|
+
# direction. A push is the act that makes content public and is not undoable, so an incomplete
|
|
216
|
+
# instrument BLOCKS here, whereas the same state only warns at commit time (a commit is local and
|
|
217
|
+
# re-committable, and the operator override is gitignored — absent on every fresh clone by
|
|
218
|
+
# construction, so blocking there would train the override into a reflex).
|
|
219
|
+
# APPLICABILITY FIRST, before anything is required to exist. The mechanical test for "is this an FH
|
|
220
|
+
# checkout at all" is the COMMITTED pattern source; a bare repo with this hook copied into it (this
|
|
221
|
+
# repo's own selfcheck fixture is exactly that) has neither the patterns nor the library, and demanding
|
|
222
|
+
# them there is over-blocking, not fail-closed. Deleting the defaults to reach this state is not a free
|
|
223
|
+
# bypass — it is a commit against a .claude/rules/ path, which the commit gate treats as HEAVY and
|
|
224
|
+
# universal_guard_check pins in both directions.
|
|
225
|
+
PSA_LIB="$REPO_ROOT/scripts/psa_scan_lib.sh"
|
|
226
|
+
_PP_APPLICABLE=1
|
|
227
|
+
if [ ! -e "$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults" ]; then
|
|
228
|
+
_PP_APPLICABLE=0
|
|
229
|
+
fi
|
|
230
|
+
if [ "$_PP_APPLICABLE" -eq 0 ]; then
|
|
231
|
+
# An inapplicable check must SAY it is inapplicable. A silent skip is indistinguishable from a
|
|
232
|
+
# check that aborted, and "skipped" reading as "passed" is the defect class this whole gate exists
|
|
233
|
+
# to remove — including when the skip is correct.
|
|
234
|
+
echo " ⏭️ FH Pre-Publish: N/A — no committed pattern source in this repo (not an FH checkout)"
|
|
235
|
+
elif [ ! -r "$PSA_LIB" ]; then
|
|
236
|
+
# The pattern source IS here, so this IS an FH checkout — a missing library is then a broken
|
|
237
|
+
# instrument on a publish surface, not an inapplicable one.
|
|
238
|
+
echo " ❌ scripts/psa_scan_lib.sh missing — the confidentiality scanner cannot run."
|
|
239
|
+
[ "${PUBLIC_SURFACE_OK:-0}" = "1" ] || { echo " Fail-closed on the publish boundary."; exit 1; }
|
|
240
|
+
else
|
|
241
|
+
. "$PSA_LIB"
|
|
242
|
+
psa_load "$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults" \
|
|
243
|
+
"${PSA_PATTERNS:-$REPO_ROOT/.claude/rules/.public-surface-patterns}"
|
|
244
|
+
|
|
245
|
+
# Instrument completeness — and a CORRECTION to how this was first written (2026-07-26).
|
|
246
|
+
#
|
|
247
|
+
# The first version blocked on an absent operator override. Two things then showed that was wrong:
|
|
248
|
+
# this repo's own selfcheck flagged it as over-blocking (T7: a feature-branch push blocked → "guard
|
|
249
|
+
# over-fires; that trains the override"), and the reasoning did not survive re-examination. The
|
|
250
|
+
# override holds THIS operator's literals. Another environment lacking it is not thereby unprotected
|
|
251
|
+
# against ITS OWN leaks — those literals were never in the file. The earlier argument (npm publish is
|
|
252
|
+
# not a backstop for a public git push) was right about the gap and wrong about the fix: the thing
|
|
253
|
+
# that actually protects a fresh clone is GENERIC credential shapes in the COMMITTED layer, which is
|
|
254
|
+
# exactly what was added to the defaults in the same session. So:
|
|
255
|
+
# override absent → WARN (a per-operator configuration a fresh clone legitimately lacks)
|
|
256
|
+
# defaults broken → BLOCK (the shipped universal patterns are gone; that affects everyone)
|
|
257
|
+
#
|
|
258
|
+
# Applicability is mechanical, not self-judged (CLAUDE.md §Surface-Class Degrade Invariant): if the
|
|
259
|
+
# COMMITTED defaults file does not exist at all, this is not an FH repo and the confidentiality legs
|
|
260
|
+
# do not apply — a bare repo with this hook copied in is not a publish surface this gate knows how to
|
|
261
|
+
# reason about. Deleting the file to reach that state is not a free bypass: it is a commit against a
|
|
262
|
+
# .claude/rules/ path, which the commit gate treats as HEAVY and universal_guard_check pins.
|
|
263
|
+
_pp_why=""
|
|
264
|
+
[ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_DEFAULTS_OK" -eq 0 ] && _pp_why="committed pattern defaults unreadable/empty"
|
|
265
|
+
[ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_BAD_ROWS" -gt 0 ] && _pp_why="${_pp_why:+$_pp_why; }$PSA_BAD_ROWS unusable pattern row(s)"
|
|
266
|
+
if [ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_OVERRIDE_PRESENT" -eq 0 ]; then
|
|
267
|
+
echo " ⚠️ operator-literal override absent — only the committed defaults (home paths + credential"
|
|
268
|
+
echo " shapes) are active. Populate .claude/rules/.public-surface-patterns for company literals."
|
|
269
|
+
fi
|
|
270
|
+
if [ -n "$_pp_why" ]; then
|
|
271
|
+
if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
|
|
272
|
+
echo " ⚠️ FH Pre-Publish: INCOMPLETE confidentiality instrument allowed by PUBLIC_SURFACE_OK=1"
|
|
273
|
+
echo " ($_pp_why)"
|
|
274
|
+
printf '%s PUBLIC_SURFACE_OK override (git push, incomplete instrument: %s)\n' \
|
|
275
|
+
"$(date +%Y-%m-%dT%H:%M:%S)" "$_pp_why" \
|
|
276
|
+
>> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
|
|
277
|
+
else
|
|
278
|
+
echo ""
|
|
279
|
+
echo "══════════════════════════════════════════════"
|
|
280
|
+
echo " ⛔ FH Pre-Publish Gate (pre-push) — incomplete confidentiality instrument"
|
|
281
|
+
echo "══════════════════════════════════════════════"
|
|
282
|
+
echo " $_pp_why"
|
|
283
|
+
echo ""
|
|
284
|
+
echo " A push makes content public. The per-commit scan only WARNS about the missing override,"
|
|
285
|
+
echo " and the npm publish scan never sees a git push — so this is the boundary that must hold."
|
|
286
|
+
echo " Populate .claude/rules/.public-surface-patterns (gitignored), or push consciously:"
|
|
287
|
+
echo " PUBLIC_SURFACE_OK=1 git push …"
|
|
288
|
+
echo "══════════════════════════════════════════════"
|
|
289
|
+
exit 1
|
|
290
|
+
fi
|
|
291
|
+
fi
|
|
292
|
+
|
|
293
|
+
# Content. Scans the ADDED lines of every commit this push publishes — per commit, not the net
|
|
294
|
+
# diff: a token added and later removed still ships inside the pushed history. Lines are tagged
|
|
295
|
+
# with their path so the LOW file allowlist can apply (flattening them is what made this leg block
|
|
296
|
+
# its own first real push on a companion-store name inside the sync script itself).
|
|
297
|
+
if [ -n "$PUSH_RANGES" ] && [ "$_PP_APPLICABLE" -eq 1 ]; then
|
|
298
|
+
_pp_count=0; _pp_added=""; _pp_err=0
|
|
299
|
+
while IFS= read -r _rg; do
|
|
300
|
+
[ -z "$_rg" ] && continue
|
|
301
|
+
_c=$(git rev-list --count $_rg 2>/dev/null) || { _pp_err=1; continue; }
|
|
302
|
+
_pp_count=$(( _pp_count + ${_c:-0} ))
|
|
303
|
+
_d=$(git -c core.quotePath=false log --format= --unified=0 $_rg 2>/dev/null) || { _pp_err=1; continue; }
|
|
304
|
+
_pp_added="$_pp_added$SEP$_d"
|
|
305
|
+
done <<PPRANGES
|
|
306
|
+
$PUSH_RANGES
|
|
307
|
+
PPRANGES
|
|
308
|
+
if [ "$_pp_err" -eq 1 ]; then
|
|
309
|
+
echo " ❌ could not read part of the push range — an unread history is not a clean one."
|
|
310
|
+
[ "${PUBLIC_SURFACE_OK:-0}" = "1" ] || exit 1
|
|
311
|
+
fi
|
|
312
|
+
if [ "${_pp_count:-0}" -gt 0 ]; then
|
|
313
|
+
if printf '%s\n' "$_pp_added" | awk '
|
|
314
|
+
/^\+\+\+ b\// { f = substr($0, 7); next }
|
|
315
|
+
/^\+/ { print f "\t" substr($0, 2) }
|
|
316
|
+
' | psa_scan_tagged; then
|
|
317
|
+
echo " ✅ FH Pre-Publish: no operator-private token in the TEXT added by ${_pp_count} commit(s)"
|
|
318
|
+
echo " (not covered: annotated-tag messages · binary blobs — named residuals)"
|
|
319
|
+
else
|
|
320
|
+
if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
|
|
321
|
+
echo " ⚠️ pushing a flagged token by PUBLIC_SURFACE_OK=1 (conscious, reviewed)"
|
|
322
|
+
printf '%s PUBLIC_SURFACE_OK override (git push, content hit in pushed history)\n' \
|
|
323
|
+
"$(date +%Y-%m-%dT%H:%M:%S)" >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
|
|
324
|
+
else
|
|
325
|
+
echo ""
|
|
326
|
+
echo "══════════════════════════════════════════════"
|
|
327
|
+
echo " ⛔ FH Pre-Publish Gate (pre-push) — token in the history being pushed"
|
|
328
|
+
echo "══════════════════════════════════════════════"
|
|
329
|
+
echo " A push publishes every commit in the range, not just the tip. Rewrite the offending"
|
|
330
|
+
echo " commit(s) (git rebase -i / filter-repo) — removing the line in a NEW commit does not"
|
|
331
|
+
echo " un-publish it once pushed."
|
|
332
|
+
echo " Deliberate mention: PUBLIC_SURFACE_OK=1 git push … (logged)"
|
|
333
|
+
echo "══════════════════════════════════════════════"
|
|
334
|
+
exit 1
|
|
335
|
+
fi
|
|
336
|
+
fi
|
|
337
|
+
fi
|
|
338
|
+
fi
|
|
339
|
+
fi
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── Load-bearing change: the cross-family leg must have RUN, not just been stated ─────────────
|
|
343
|
+
# The commit hook requires a `crossfamily:` line to EXIST (it blocks silence, not a 'none'), because
|
|
344
|
+
# a commit is local and reversible. A push is where the change leaves this machine and becomes
|
|
345
|
+
# something another party will review — and the review that currently catches this defect class is
|
|
346
|
+
# not a human reading a diff, it is someone RUNNING A HARNESS over the PR. That catch is real and it
|
|
347
|
+
# is what this leg pulls forward: if a different-family auditor is going to find it anyway, it should
|
|
348
|
+
# find it before the PR exists, not after, so the fix and the skill-strengthening land in the same
|
|
349
|
+
# breath instead of as a follow-up round.
|
|
350
|
+
# Same escalation shape as the confidentiality instrument above: WARN at commit, REQUIRE at the
|
|
351
|
+
# boundary that publishes. `none` is still an acceptable answer here — but it must be a REASON, not
|
|
352
|
+
# an empty field, and the push is where that answer stops being free.
|
|
353
|
+
_LB_RAW=""
|
|
354
|
+
while IFS= read -r _r; do
|
|
355
|
+
[ -z "$_r" ] && continue
|
|
356
|
+
_LB_RAW="$_LB_RAW$SEP$(git -c core.quotePath=false log --name-only --format= $_r 2>/dev/null || true)"
|
|
357
|
+
done <<LBR
|
|
358
|
+
$PUSH_RANGES
|
|
359
|
+
LBR
|
|
360
|
+
_LB_FILES=$(printf '%s\n' "$_LB_RAW" \
|
|
361
|
+
| grep -E '(templates/\.git-hooks/|scripts/(fh-gate|degrade_direction_scan|universal_guard_check|public_surface_scan_files|gate_pathspec_check|predelete_check)\.sh|\.claude/rules/\.public-surface-patterns)' \
|
|
362
|
+
| sort -u || true)
|
|
363
|
+
# Also FH-dev-scoped: the marker this requires lives in tracks/_meta, which only an FH checkout has.
|
|
364
|
+
# An outside contributor pushing a gate file cannot produce an FH marker, and blocking them would make
|
|
365
|
+
# the repo hostile to contribution while protecting nothing they could act on.
|
|
366
|
+
if [ -n "$PUSH_RANGES" ] && [ -n "$_LB_FILES" ] && [ -d "$REPO_ROOT/tracks/_meta" ]; then
|
|
367
|
+
_cf_marker=$(ls -t "$REPO_ROOT"/tracks/_meta/.axes_23_passed_*.marker 2>/dev/null | head -1)
|
|
368
|
+
_cf_line=$(grep -m1 -E '^[[:space:]]*crossfamily:[[:space:]]*[^[:space:]]' "$_cf_marker" 2>/dev/null || true)
|
|
369
|
+
if [ -z "$_cf_line" ]; then
|
|
370
|
+
echo ""
|
|
371
|
+
echo "══════════════════════════════════════════════"
|
|
372
|
+
echo " ⛔ FH Load-Bearing Change Gate (pre-push) — no cross-family leg recorded"
|
|
373
|
+
echo "══════════════════════════════════════════════"
|
|
374
|
+
echo " This push carries gate / verdict / irreversible-surface code, and no marker records"
|
|
375
|
+
echo " whether a DIFFERENT-family auditor saw it."
|
|
376
|
+
echo ""
|
|
377
|
+
echo " A same-family reviewer shares the author's optimistic reading; that is the whole reason"
|
|
378
|
+
echo " this gate exists. The catch usually happens anyway — later, when someone runs a harness"
|
|
379
|
+
echo " over the PR. Running it now is the same work, one round earlier."
|
|
380
|
+
echo ""
|
|
381
|
+
echo " Record the answer in the Axes 2-3 marker, then push:"
|
|
382
|
+
echo " crossfamily: <engine/model> — <rounds, findings, verdict>"
|
|
383
|
+
echo " crossfamily: none — <why none was reachable or needed>"
|
|
384
|
+
echo " Conscious exception: PUBLIC_SURFACE_OK=1 git push … (logged)"
|
|
385
|
+
echo "══════════════════════════════════════════════"
|
|
386
|
+
if [ "${PUBLIC_SURFACE_OK:-0}" != "1" ]; then exit 1; fi
|
|
387
|
+
echo " ⚠️ proceeding without a recorded cross-family leg by PUBLIC_SURFACE_OK=1"
|
|
388
|
+
printf '%s PUBLIC_SURFACE_OK override (git push, no cross-family leg recorded)\n' \
|
|
389
|
+
"$(date +%Y-%m-%dT%H:%M:%S)" >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
|
|
390
|
+
else
|
|
391
|
+
echo " ✅ FH Load-Bearing Change Gate: $(printf '%s' "$_cf_line" | cut -c1-88)"
|
|
392
|
+
fi
|
|
393
|
+
fi
|
|
394
|
+
|
|
395
|
+
# ── PR-only policy for the integration branch (operator decision 2026-07-20) ──────
|
|
396
|
+
# WHY THIS EXISTS LOCALLY: it is the SHIFT-LEFT half of a two-layer floor.
|
|
397
|
+
# History: on 2026-07-20 the server required a PR but had `enforce_admins: false`, so an admin
|
|
398
|
+
# push satisfied the API and only printed "Bypassed rule violations" — a NOTICE, not a block.
|
|
399
|
+
# A rule that announces its own bypass is not a floor ([[feedback_non_defeasible_floor]]).
|
|
400
|
+
# The operator then flipped `enforce_admins: true` (+ `required_approving_review_count: 0` so a
|
|
401
|
+
# solo operator can still merge their own PR), so the server IS now the hard floor.
|
|
402
|
+
# This hook still earns its place: it fails at push time with the actual remedy in the message,
|
|
403
|
+
# instead of a bare server rejection, and it keeps working if the server setting is ever relaxed.
|
|
404
|
+
# It is deliberately NOT the hard floor — a client-side hook is bypassable by `--no-verify`.
|
|
405
|
+
# Scope: blocks the PUSH, not the merge — `gh pr merge` operates server-side and is unaffected.
|
|
406
|
+
if [ -n "$DIRECT_MAIN" ] && [ "${MAIN_PUSH_OK:-0}" != "1" ]; then
|
|
407
|
+
echo ""
|
|
408
|
+
echo "══════════════════════════════════════════════"
|
|
409
|
+
echo " ⛔ FH PR-Only Policy (pre-push)"
|
|
410
|
+
echo "══════════════════════════════════════════════"
|
|
411
|
+
echo " Direct push to the integration branch:$DIRECT_MAIN"
|
|
412
|
+
echo ""
|
|
413
|
+
echo " main is PR-only (operator decision 2026-07-20). The server enforces this too"
|
|
414
|
+
echo " (enforce_admins: true, review_count: 0) — this hook just fails earlier, with the fix."
|
|
415
|
+
echo ""
|
|
416
|
+
echo " Normal path:"
|
|
417
|
+
echo " git switch -c <branch> && git push -u origin <branch>"
|
|
418
|
+
echo " gh pr create --fill"
|
|
419
|
+
echo " # after review: gh pr merge --squash --delete-branch --admin"
|
|
420
|
+
echo ""
|
|
421
|
+
echo " Deliberate exception (explicit, logged — mirrors DESTRUCTIVE_OP_OK / PUBLIC_SURFACE_OK):"
|
|
422
|
+
echo " MAIN_PUSH_OK=1 git push …"
|
|
423
|
+
exit 1
|
|
424
|
+
fi
|
|
425
|
+
if [ -n "$DIRECT_MAIN" ]; then
|
|
426
|
+
echo " ⚠️ FH PR-Only Policy: direct push to$DIRECT_MAIN allowed by MAIN_PUSH_OK=1 (conscious, gated intent)"
|
|
427
|
+
fi
|
|
428
|
+
|
|
429
|
+
# ── Session-close check (CLAUDE.md §Session Wrap-up ①–⑥) ─────────────────────────
|
|
430
|
+
# WIRED 2026-07-20. Before this, scripts/session_close_check.sh was referenced ONLY by prose
|
|
431
|
+
# (CLAUDE.md + 2 knowledge docs + itself — grep-verified 0 hook references), while CLAUDE.md
|
|
432
|
+
# advertised it as a "mechanical floor" that "blocks the push step". Nothing ran it. A floor that
|
|
433
|
+
# only runs when the session remembers to run it is not a floor ([[feedback_non_defeasible_floor]],
|
|
434
|
+
# §gate-locality). Found by a Fable judgment pass on CLAUDE.md residency, source-closed by grep.
|
|
435
|
+
#
|
|
436
|
+
# WHY ADVISORY BY DEFAULT (and not a hard block on every push): the script's blocking invariants are
|
|
437
|
+
# CLOSE-TIME invariants. ⑤ card-last requires the session card to be the NEWEST close artifact — but
|
|
438
|
+
# CLAUDE.md separately mandates appending to fh_completed_<date>.md **immediately** during the session.
|
|
439
|
+
# So mid-session, an obedient runner necessarily makes the card momentarily stale. Blocking every push
|
|
440
|
+
# on ⑤ would put two documented rules in direct conflict and train the operator to --no-verify, which
|
|
441
|
+
# would disarm the Destructive-Op gate below it. Degrade direction is defensible here because an
|
|
442
|
+
# ordinary branch push is REVERSIBLE (§Irreversibility Surface-Class Degrade Invariant: reversible
|
|
443
|
+
# surface → advisory; only publish/delete/rewrite fail closed).
|
|
444
|
+
#
|
|
445
|
+
# ENFORCING FORM (use at actual session close, step ⑥): FH_SESSION_CLOSE=1 git push …
|
|
446
|
+
if [ -x "$REPO_ROOT/scripts/session_close_check.sh" ] || [ -f "$REPO_ROOT/scripts/session_close_check.sh" ]; then
|
|
447
|
+
# ⚠️ `< /dev/null` is DEFENSE IN DEPTH — keep it, but it is not what makes this safe.
|
|
448
|
+
# git feeds the push ref list on the hook's STDIN, and a subprocess started here INHERITS stdin.
|
|
449
|
+
# If such a helper reads stdin it drains the ref list; a classification loop running AFTERWARDS
|
|
450
|
+
# would then see ZERO refs, leave every DEL_/FORCED_ var empty, and fall into the
|
|
451
|
+
# "nothing destructive → ordinary ff push → exit 0" path — silently DISARMING the Destructive-Op
|
|
452
|
+
# gate on exactly the push it exists to stop. Empirically reproduced 2026-07-20.
|
|
453
|
+
# THE STRUCTURAL FIX IS THE POSITION, NOT THIS REDIRECT: the classification loop above has already
|
|
454
|
+
# consumed stdin into variables before we get here, so draining fd 0 now costs nothing.
|
|
455
|
+
# If anyone ever moves this block back above that loop, the redirect alone is a thin guard —
|
|
456
|
+
# scripts/test_prepush_stdin_integrity.sh asserts the ordering for exactly that reason.
|
|
457
|
+
_SC_OUT=$(bash "$REPO_ROOT/scripts/session_close_check.sh" "$REPO_ROOT" 2>&1 < /dev/null); _SC_RC=$?
|
|
458
|
+
if [ "${FH_SESSION_CLOSE:-0}" = "1" ]; then
|
|
459
|
+
printf '%s\n' "$_SC_OUT"
|
|
460
|
+
if [ "$_SC_RC" -ne 0 ]; then
|
|
461
|
+
echo ""
|
|
462
|
+
echo " ⛔ FH Session-Close Check: close invariant violated (FH_SESSION_CLOSE=1 → enforcing)."
|
|
463
|
+
echo " Fix the ❌ line(s) above — card-last means ⑤ runs AFTER ①–④-c, never before."
|
|
464
|
+
exit 1
|
|
465
|
+
fi
|
|
466
|
+
echo " ✅ FH Session-Close Check: close state consistent."
|
|
467
|
+
elif [ "$_SC_RC" -ne 0 ]; then
|
|
468
|
+
# Not a close push: surface the violations, never block.
|
|
469
|
+
printf '%s\n' "$_SC_OUT" | grep '❌' || true
|
|
470
|
+
echo " ⚠️ FH Session-Close Check: close invariant(s) violated (advisory — this is not a close push)."
|
|
471
|
+
echo " At session close, enforce with: FH_SESSION_CLOSE=1 git push …"
|
|
472
|
+
fi
|
|
473
|
+
fi
|
|
474
|
+
|
|
475
|
+
# NOTE ON POSITION (moved here 2026-07-20 after a cross-family audit returned NOT-CONVERGED):
|
|
476
|
+
# this block ORIGINALLY sat above the ref-reading loop. That was a priority inversion — an
|
|
477
|
+
# ADVISORY check placed above a BLOCKING safety gate — and it opened an fd-0 hazard: the helper
|
|
478
|
+
# subprocess inherits stdin, git delivers the push ref list on stdin, so a helper that read stdin
|
|
479
|
+
# would drain the ref list and the loop below would classify ZERO destructive refs and allow the
|
|
480
|
+
# push. Running AFTER classification removes the hazard structurally (the refs are already read
|
|
481
|
+
# into variables); the `< /dev/null` guard is kept as defense in depth, not as the fix.
|
|
482
|
+
# Nothing destructive and nothing unclassifiable → ordinary ff push / new branch → allow.
|
|
483
|
+
if [ -z "$DEL_BRANCHES$DEL_OTHER$FORCED_REFS$UNCLASSIFIED" ]; then
|
|
484
|
+
exit 0
|
|
485
|
+
fi
|
|
486
|
+
|
|
487
|
+
# ── Explicit, logged operator acknowledgment (enumerate+recover done out-of-band) ──
|
|
488
|
+
if [ "${DESTRUCTIVE_OP_OK:-0}" = "1" ]; then
|
|
489
|
+
echo " ⚠️ FH Destructive-Op Gate: allowed by DESTRUCTIVE_OP_OK=1 (conscious, gated intent)"
|
|
490
|
+
LOG="$REPO_ROOT/tracks/_meta/.destructive_op_override_log"
|
|
491
|
+
if mkdir -p "$REPO_ROOT/tracks/_meta" 2>/dev/null && \
|
|
492
|
+
printf '%s DESTRUCTIVE_OP_OK override — del:%s other:%s forced:%s unclassified:%s\n' \
|
|
493
|
+
"$(date +%Y-%m-%dT%H:%M:%S)" "${DEL_BRANCHES:-none}" "${DEL_OTHER:-none}" \
|
|
494
|
+
"${FORCED_REFS:-none}" "${UNCLASSIFIED:-none}" >> "$LOG" 2>/dev/null; then
|
|
495
|
+
:
|
|
496
|
+
else
|
|
497
|
+
echo " ⚠️ (override could not be logged to $LOG — proceeding, but this override is UNRECORDED)"
|
|
498
|
+
fi
|
|
499
|
+
exit 0
|
|
500
|
+
fi
|
|
501
|
+
|
|
502
|
+
echo "══════════════════════════════════════════════"
|
|
503
|
+
echo " ⛔ FH Destructive-Op Gate (pre-push)"
|
|
504
|
+
echo "══════════════════════════════════════════════"
|
|
505
|
+
|
|
506
|
+
BLOCK=0
|
|
507
|
+
|
|
508
|
+
# ── Force / non-ff pushes: always block (a rewrite always loses the old commits) ──
|
|
509
|
+
for r in $FORCED_REFS; do
|
|
510
|
+
echo " FORCE / non-fast-forward (history rewrite): $r"
|
|
511
|
+
echo " → take a bundle backup first: git bundle create backup.bundle --all"
|
|
512
|
+
BLOCK=1
|
|
513
|
+
done
|
|
514
|
+
|
|
515
|
+
# ── Unclassifiable (remote tip not fetched): fail-closed, accurate message ──
|
|
516
|
+
for r in $UNCLASSIFIED; do
|
|
517
|
+
echo " CANNOT CLASSIFY (remote tip not fetched): $r"
|
|
518
|
+
echo " → run 'git fetch' so a force-push can be distinguished from a fast-forward, then re-push."
|
|
519
|
+
BLOCK=1
|
|
520
|
+
done
|
|
521
|
+
|
|
522
|
+
# ── Tag / notes deletes: block + ref-specific note (predelete_check walks branches only) ──
|
|
523
|
+
for r in $DEL_OTHER; do
|
|
524
|
+
echo " DELETE (non-branch ref): $r"
|
|
525
|
+
echo " → tags/notes carry no unique paths but may be external anchors (a release tag, a"
|
|
526
|
+
echo " published note). Confirm nothing references it before deleting."
|
|
527
|
+
BLOCK=1
|
|
528
|
+
done
|
|
529
|
+
|
|
530
|
+
# ── Branch deletes: per-ref verdict (SAFE auto-allows; CHECK/REVIEW block) ──
|
|
531
|
+
# Makes the enumerate load-bearing instead of decorative: a fully-merged branch (nothing
|
|
532
|
+
# unique lost) passes; a branch with unique paths or commits off base is held.
|
|
533
|
+
if [ -n "$DEL_BRANCHES" ]; then
|
|
534
|
+
if ! git rev-parse --verify --quiet "$BASE" >/dev/null 2>&1; then
|
|
535
|
+
echo " ⚠️ base '$BASE' unresolvable — cannot verify SAFE → all branch deletes BLOCKED (fail-closed)."
|
|
536
|
+
echo " (set FH_DESTRUCTIVE_BASE=<ref> or fetch the base, then re-push.)"
|
|
537
|
+
for r in $DEL_BRANCHES; do echo " DELETE (branch, unverified): $r"; done
|
|
538
|
+
BLOCK=1
|
|
539
|
+
else
|
|
540
|
+
BASE_BRANCH="${BASE##*/}" # last path component: origin/main / refs/remotes/origin/main / refs/heads/main → main
|
|
541
|
+
for pair in $DEL_BRANCHES; do
|
|
542
|
+
tip="${pair%%|*}"; r="${pair#*|}" # SHA is before the first '|' (hex, no '|'); ref is everything after
|
|
543
|
+
# Deleting the integration branch itself is never "SAFE" — it is trivially "merged into
|
|
544
|
+
# itself" (n=0, uniq=0) and would auto-pass. Guard it explicitly.
|
|
545
|
+
if [ "$r" = "refs/heads/${BASE_BRANCH}" ] || [ "$r" = "refs/heads/master" ] || [ "$r" = "refs/heads/main" ]; then
|
|
546
|
+
echo " DELETE (branch): $r — PROTECTED: this is the integration branch → BLOCKED (never auto-SAFE)"
|
|
547
|
+
BLOCK=1; continue
|
|
548
|
+
fi
|
|
549
|
+
if [ "$tip" = "$ZERO" ] || ! git cat-file -e "${tip}^{commit}" 2>/dev/null; then
|
|
550
|
+
echo " DELETE (branch): $r — tip not local, cannot verify → BLOCKED (fail-closed)"
|
|
551
|
+
BLOCK=1; continue
|
|
552
|
+
fi
|
|
553
|
+
n=$(git rev-list --count "$BASE..$tip" 2>/dev/null || echo "?")
|
|
554
|
+
uniq=$(comm -23 \
|
|
555
|
+
<(git ls-tree -r --name-only "$tip" 2>/dev/null | sort) \
|
|
556
|
+
<(git ls-tree -r --name-only "$BASE" 2>/dev/null | sort) | grep -c . || true)
|
|
557
|
+
if [ "${uniq:-0}" -gt 0 ] 2>/dev/null; then
|
|
558
|
+
echo " DELETE (branch): $r — REVIEW: $uniq unique path(s), $n commit(s) off $BASE → recover BEFORE deleting"
|
|
559
|
+
BLOCK=1
|
|
560
|
+
elif [ "${n:-0}" != "0" ]; then
|
|
561
|
+
echo " DELETE (branch): $r — CHECK: $n commit(s) off $BASE, 0 unique paths → judged content look first"
|
|
562
|
+
echo " (a shared file may hold NEWER content, e.g. an unmerged session card — the silent-loss class)"
|
|
563
|
+
BLOCK=1
|
|
564
|
+
else
|
|
565
|
+
echo " DELETE (branch): $r — SAFE: fully merged into $BASE, nothing unique lost → allowed"
|
|
566
|
+
fi
|
|
567
|
+
done
|
|
568
|
+
fi
|
|
569
|
+
fi
|
|
570
|
+
|
|
571
|
+
if [ "$BLOCK" -eq 0 ]; then
|
|
572
|
+
echo ""
|
|
573
|
+
echo " ✅ all destructive refs verified SAFE (fully merged) — allowing push."
|
|
574
|
+
exit 0
|
|
575
|
+
fi
|
|
576
|
+
|
|
577
|
+
echo ""
|
|
578
|
+
echo " Gate order: enumerate → recover (integrate live un-merged state to the base) → destroy."
|
|
579
|
+
echo " This hook is the honest-model floor (it stops a forgotten gate). The hard floor for an"
|
|
580
|
+
echo " adversarial/injected agent is SERVER-SIDE branch protection (GitHub: Restrict deletions /"
|
|
581
|
+
echo " Restrict force pushes) — a client-side hook is bypassable by design."
|
|
582
|
+
echo ""
|
|
583
|
+
echo " When enumerate + recover are done, the OPERATOR re-pushes with the explicit acknowledgment:"
|
|
584
|
+
echo " DESTRUCTIVE_OP_OK=1 git push …"
|
|
585
|
+
exit 1
|