@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,374 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# prepush_guard_check.sh — known-pair anchor for the pre-push PUBLISH-boundary guards.
|
|
3
|
+
#
|
|
4
|
+
# Sibling of scripts/universal_guard_check.sh (which anchors the pre-COMMIT universal guards).
|
|
5
|
+
# This one anchors what pre-push added on 2026-07-26: the confidentiality CONTENT scan over the
|
|
6
|
+
# commits a push would actually publish, and the load-bearing cross-family acknowledgment.
|
|
7
|
+
#
|
|
8
|
+
# WHY IT EXISTS AS A SCRIPT AND NOT AS A CHECKLIST — measured, twice, in one session:
|
|
9
|
+
# While repairing this very hook, TWO fail-opens were authored into it and neither was caught by
|
|
10
|
+
# the checks in use at the time:
|
|
11
|
+
# (1) `git show $(git rev-list …)` expanded every SHA into argv; on a large push that hits
|
|
12
|
+
# ARG_MAX, git dies, the capture is empty, and an empty capture reads as "no hits" → PASS.
|
|
13
|
+
# (2) A mangled `${...}` produced a RUNTIME "bad substitution" that aborted the hook — and the
|
|
14
|
+
# hook still exited 0, i.e. every push allowed. `bash -n` PASSED on that file: bad
|
|
15
|
+
# substitution is a runtime error, not a parse error. Syntax-checking a gate is not testing it.
|
|
16
|
+
# So this anchor always runs the hook FOR REAL and greps the output for runtime faults, in addition
|
|
17
|
+
# to checking verdicts. A gate that aborts must never be readable as a gate that passed.
|
|
18
|
+
#
|
|
19
|
+
# Runs in throwaway repos (mktemp): never touches this repo's index or worktree.
|
|
20
|
+
# Usage: bash scripts/prepush_guard_check.sh → exit 0 all pairs hold, 1 otherwise.
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
24
|
+
HOOK_SRC="$REPO_ROOT/templates/.git-hooks/pre-push"
|
|
25
|
+
DEF_SRC="$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults"
|
|
26
|
+
[ -f "$HOOK_SRC" ] || { echo "❌ FAIL — pre-push hook not found"; exit 1; }
|
|
27
|
+
[ -f "$DEF_SRC" ] || { echo "❌ FAIL — pattern defaults not found"; exit 1; }
|
|
28
|
+
|
|
29
|
+
# Test the STAGED blob when the hook is staged — same reasoning as universal_guard_check.sh: an
|
|
30
|
+
# anchor that reads the worktree can be bypassed by staging a regression and restoring the worktree.
|
|
31
|
+
WORK=$(mktemp -d) || exit 1
|
|
32
|
+
trap 'rm -rf "$WORK"' EXIT
|
|
33
|
+
_status=$(git -C "$REPO_ROOT" -c core.quotePath=false diff --cached --name-status --no-renames 2>/dev/null \
|
|
34
|
+
| awk -F'\t' '$2 == "templates/.git-hooks/pre-push" { print $1; exit }')
|
|
35
|
+
case "$_status" in
|
|
36
|
+
D) echo "❌ FAIL — pre-push is being DELETED from the index — fail-closed."; exit 1 ;;
|
|
37
|
+
'') cp "$HOOK_SRC" "$WORK/hook" ;;
|
|
38
|
+
*) git -C "$REPO_ROOT" show ":templates/.git-hooks/pre-push" > "$WORK/hook" 2>/dev/null \
|
|
39
|
+
|| { echo "❌ FAIL — staged pre-push blob unreadable — fail-closed."; exit 1; } ;;
|
|
40
|
+
esac
|
|
41
|
+
HOOK="$WORK/hook"
|
|
42
|
+
|
|
43
|
+
FAILED=0
|
|
44
|
+
# Fixtures assembled at runtime so this file's own bytes carry no matching credential shape
|
|
45
|
+
# (same rule as universal_guard_check.sh — a fixture file excluded from scanning would be a hole).
|
|
46
|
+
AWSK="AKIA""1234567890ABCDEF"
|
|
47
|
+
PATK="ghp""_abcdefghijklmnopqrstuvwxyz012345"
|
|
48
|
+
DOCK="AKIAIOSFODNN7EXAMPLE" # the documented example key: must be exempt, so it stays literal
|
|
49
|
+
|
|
50
|
+
newrepo() { # echoes a fresh repo path with the pattern layers in place and one base commit
|
|
51
|
+
local d; d=$(mktemp -d)
|
|
52
|
+
mkdir -p "$d/.claude/rules" "$d/templates/.git-hooks" "$d/tracks/_meta"
|
|
53
|
+
cp "$DEF_SRC" "$d/.claude/rules/.public-surface-patterns.defaults"
|
|
54
|
+
# The operator override is GITIGNORED in the real repo, so it never enters history. Committing it
|
|
55
|
+
# here made its own literals show up as leaks in the pushed content — a fixture artifact that read
|
|
56
|
+
# exactly like a product defect. Mirror reality instead.
|
|
57
|
+
printf '.claude/rules/.public-surface-patterns\n' > "$d/.gitignore"
|
|
58
|
+
printf 'HIGH\tzzsynthoperator\n' > "$d/.claude/rules/.public-surface-patterns"
|
|
59
|
+
cp "$HOOK" "$d/templates/.git-hooks/pre-push"
|
|
60
|
+
mkdir -p "$d/scripts"
|
|
61
|
+
cp "$REPO_ROOT/scripts/psa_scan_lib.sh" "$d/scripts/psa_scan_lib.sh"
|
|
62
|
+
( cd "$d" && git init -q -b main && git config user.email t@example.com && git config user.name t \
|
|
63
|
+
&& git add -A >/dev/null 2>&1 && git commit -qm base >/dev/null 2>&1 ) || return 1
|
|
64
|
+
printf '%s' "$d"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# check <name> <repo> <expect: block|pass> <refline...>
|
|
68
|
+
check() {
|
|
69
|
+
local name="$1" repo="$2" expect="$3"; shift 3
|
|
70
|
+
local out rc got
|
|
71
|
+
out=$(printf '%s\n' "$@" | ( cd "$repo" && bash templates/.git-hooks/pre-push origin git@example:x/y.git 2>&1 )); rc=$?
|
|
72
|
+
# A runtime fault is its own failure mode, checked BEFORE the verdict: an aborted hook that exits 0
|
|
73
|
+
# would otherwise be scored as a clean pass — the exact defect this anchor was written for.
|
|
74
|
+
if printf '%s' "$out" | grep -qiE 'bad substitution|unbound variable|syntax error|command not found'; then
|
|
75
|
+
echo " ❌ $name — RUNTIME FAULT in the hook (a hook that aborts is not a hook that passed)"
|
|
76
|
+
printf '%s\n' "$out" | grep -iE 'bad substitution|unbound|syntax error|command not found' | sed 's/^/ /' | head -3
|
|
77
|
+
FAILED=1; return
|
|
78
|
+
fi
|
|
79
|
+
# A hook killed by a signal, or one that dies printing nothing, produces no fault TEXT — the grep
|
|
80
|
+
# above cannot see it, and rc alone would score it as a pass (R7 audit, 2026-07-26). So a `pass`
|
|
81
|
+
# additionally requires the leg's own marker line: proof it ran to the point of making a claim.
|
|
82
|
+
# "It blocked" is not the same as "it blocked correctly". A missing dependency, an unreadable
|
|
83
|
+
# file, or any harness error also exits non-zero — and every BLOCK pair would score green while
|
|
84
|
+
# the gate was actually broken. (Observed: after the scan logic moved into a shared library, the
|
|
85
|
+
# sandbox repos lacked it, so 6 of 8 BLOCK pairs still "passed" — for the wrong reason.) A block
|
|
86
|
+
# therefore has to name a confidentiality cause.
|
|
87
|
+
if [ "$rc" -ne 0 ]; then
|
|
88
|
+
if printf '%s' "$out" | grep -qE '(leak —|leak in pushed|incomplete confidentiality instrument|unusable pattern)'; then
|
|
89
|
+
got=block
|
|
90
|
+
else
|
|
91
|
+
echo " ❌ $name — blocked, but for a HARNESS reason, not a confidentiality finding"
|
|
92
|
+
printf '%s\n' "$out" | sed 's/^/ | /' | head -6
|
|
93
|
+
FAILED=1; return
|
|
94
|
+
fi
|
|
95
|
+
elif printf '%s' "$out" | grep -qF 'FH Pre-Publish'; then
|
|
96
|
+
got=pass
|
|
97
|
+
else
|
|
98
|
+
echo " ❌ $name — hook exited 0 without reaching the publish check (silent abort is not a pass)"
|
|
99
|
+
printf '%s\n' "$out" | sed 's/^/ | /' | head -5
|
|
100
|
+
FAILED=1; return
|
|
101
|
+
fi
|
|
102
|
+
if [ "$got" = "$expect" ]; then
|
|
103
|
+
echo " ✅ $name (expected $expect)"
|
|
104
|
+
else
|
|
105
|
+
echo " ❌ $name — expected $expect, got $got"
|
|
106
|
+
printf '%s\n' "$out" | sed 's/^/ | /' | head -8
|
|
107
|
+
FAILED=1
|
|
108
|
+
fi
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
echo "[prepush-guard] known-pair anchor"
|
|
112
|
+
|
|
113
|
+
# ── Pair 1: a token in the pushed HISTORY, with a clean tip. Net-diff-based scanning misses this. ──
|
|
114
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
115
|
+
( cd "$R" && printf 'tok %s\n' "$PATK" > s.md && git add s.md && git commit -qm add >/dev/null \
|
|
116
|
+
&& git rm -q s.md && git commit -qm remove >/dev/null )
|
|
117
|
+
check "token added then REMOVED (history) → BLOCK" "$R" block \
|
|
118
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
119
|
+
rm -rf "$R"
|
|
120
|
+
|
|
121
|
+
# ── Pair 2: evidence truncation. Twenty documented example keys ahead of one real key used to
|
|
122
|
+
# produce zero hits AND an affirmative "no token" line — wrong out loud, not merely incomplete. ──
|
|
123
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
124
|
+
( cd "$R" && for i in $(seq 1 20); do echo "example: $DOCK"; done > d.md \
|
|
125
|
+
&& echo "real: $AWSK" >> d.md && git add d.md && git commit -qm docs >/dev/null )
|
|
126
|
+
check "20 example keys THEN a real key → BLOCK" "$R" block \
|
|
127
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
128
|
+
rm -rf "$R"
|
|
129
|
+
|
|
130
|
+
# ── Pair 3: same line, placeholder first. Taking only the first match per line hid the real token. ──
|
|
131
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
132
|
+
( cd "$R" && printf '%s then %s\n' "$DOCK" "$AWSK" > e.md && git add e.md && git commit -qm same >/dev/null )
|
|
133
|
+
check "placeholder BEFORE real, same line → BLOCK" "$R" block \
|
|
134
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
135
|
+
rm -rf "$R"
|
|
136
|
+
|
|
137
|
+
# ── Pair 4: no over-blocking. Documented example keys alone must push cleanly, or the override
|
|
138
|
+
# becomes routine and the gate is disarmed. ──
|
|
139
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
140
|
+
( cd "$R" && for i in $(seq 1 20); do echo "example: $DOCK"; done > f.md && git add f.md && git commit -qm only >/dev/null )
|
|
141
|
+
check "documented example keys only → PASS " "$R" pass \
|
|
142
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
143
|
+
rm -rf "$R"
|
|
144
|
+
|
|
145
|
+
# ── Pair 5: multi-ref push. Ranges concatenated into one arg list let `--not` from ref A flip
|
|
146
|
+
# polarity for ref B, so a token on the second branch went unseen. ──
|
|
147
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
148
|
+
( cd "$R" && git checkout -qb clean1 "$B" && echo ok > c1.md && git add c1.md && git commit -qm c1 >/dev/null \
|
|
149
|
+
&& git checkout -qb dirty2 "$B" && printf 'tok %s\n' "$PATK" > c2.md && git add c2.md && git commit -qm c2 >/dev/null )
|
|
150
|
+
C1=$(cd "$R" && git rev-parse clean1); C2=$(cd "$R" && git rev-parse dirty2)
|
|
151
|
+
check "multi-ref push, token on 2nd ref → BLOCK" "$R" block \
|
|
152
|
+
"refs/heads/clean1 $C1 refs/heads/clean1 $B" \
|
|
153
|
+
"refs/heads/dirty2 $C2 refs/heads/dirty2 $B"
|
|
154
|
+
rm -rf "$R"
|
|
155
|
+
|
|
156
|
+
# ── Pair 6: instrument completeness, and the line between "not configured" and "broken".
|
|
157
|
+
# CHANGED DELIBERATELY 2026-07-26 — this pair used to expect BLOCK on an absent operator override.
|
|
158
|
+
# Two things overturned that: selfcheck flagged it as over-blocking (T7: "guard over-fires; that
|
|
159
|
+
# trains the override"), and the reasoning did not hold — the override contains THIS operator's
|
|
160
|
+
# literals, so another environment lacking it was never protected by it anyway. What protects a fresh
|
|
161
|
+
# clone is the generic credential shapes in the COMMITTED layer. So an absent override now warns, and
|
|
162
|
+
# what still blocks is a genuinely BROKEN pattern source, which affects everyone. Both pinned. ──
|
|
163
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
164
|
+
( cd "$R" && echo ok > g.md && git add g.md && git commit -qm g >/dev/null && rm -f .claude/rules/.public-surface-patterns )
|
|
165
|
+
check "operator override absent (per-operator) → PASS " "$R" pass \
|
|
166
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
167
|
+
rm -rf "$R"
|
|
168
|
+
|
|
169
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
170
|
+
( cd "$R" && echo ok > g.md && git add g.md && git commit -qm g >/dev/null \
|
|
171
|
+
&& : > .claude/rules/.public-surface-patterns.defaults ) # present but EMPTY = broken, not unconfigured
|
|
172
|
+
check "committed defaults EMPTY (broken) → BLOCK" "$R" block \
|
|
173
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
174
|
+
rm -rf "$R"
|
|
175
|
+
|
|
176
|
+
# Applicability is mechanical: no committed pattern source at all = not an FH checkout = legs N/A.
|
|
177
|
+
# Without this the hook blocked every push in any bare repo it was copied into, which is how the
|
|
178
|
+
# over-block was found in the first place.
|
|
179
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
180
|
+
( cd "$R" && echo ok > g.md && git add g.md && git commit -qm g >/dev/null \
|
|
181
|
+
&& rm -f .claude/rules/.public-surface-patterns.defaults .claude/rules/.public-surface-patterns )
|
|
182
|
+
check "no committed pattern source (not FH) → PASS " "$R" pass \
|
|
183
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
184
|
+
rm -rf "$R"
|
|
185
|
+
|
|
186
|
+
# ── Pair 7: a pattern row with a SPACE instead of a TAB defines no detector. Silently skipping it
|
|
187
|
+
# (this copy's behaviour until the R7 sweep) certifies a push clean against a pattern that never
|
|
188
|
+
# existed. pre-commit and the publish scanner were already fail-closed; this one was missed. ──
|
|
189
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
190
|
+
( cd "$R" && printf 'HIGH zzsynthoperator\n' > .claude/rules/.public-surface-patterns \
|
|
191
|
+
&& printf 'token zzsynthoperator\n' > n.md && git add -A && git commit -qm notab >/dev/null )
|
|
192
|
+
check "pattern row with SPACE not TAB → BLOCK" "$R" block \
|
|
193
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
194
|
+
rm -rf "$R"
|
|
195
|
+
|
|
196
|
+
# ── Pair 8: a NEW branch whose commits already exist on a DIFFERENT remote. `--not --remotes`
|
|
197
|
+
# excludes commits reachable from ANY remote, so the range came out empty and the push published
|
|
198
|
+
# them to THIS remote unscanned. Simulated by planting a refs/remotes ref for another remote. ──
|
|
199
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
200
|
+
( cd "$R" && printf 'tok %s\n' "$PATK" > o.md && git add o.md && git commit -qm other >/dev/null \
|
|
201
|
+
&& git update-ref refs/remotes/otherremote/main "$(git rev-parse HEAD)" )
|
|
202
|
+
check "new branch, commits on ANOTHER remote → BLOCK" "$R" block \
|
|
203
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f 0000000000000000000000000000000000000000"
|
|
204
|
+
rm -rf "$R"
|
|
205
|
+
|
|
206
|
+
# ── Pair 9: the LOW allowlist must survive into the PUSH leg. Files like scripts/sync-to-be.sh name
|
|
207
|
+
# the companion store as part of doing their job; pre-commit exempts them at LOW severity and the
|
|
208
|
+
# push leg did not, because it had flattened the diff and kept no file context. That over-block was
|
|
209
|
+
# found by the FIRST REAL PUSH of this very change, after seven adversarial rounds missed it — an
|
|
210
|
+
# over-blocking gate trains PUBLIC_SURFACE_OK into reflex, so it is pinned in both directions. ──
|
|
211
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
212
|
+
( cd "$R" && mkdir -p scripts && printf '# backs up to zzsynthoperator\n' > scripts/sync-to-be.sh \
|
|
213
|
+
&& printf 'LOW\tzzsynthoperator\n' > .claude/rules/.public-surface-patterns \
|
|
214
|
+
&& git add -A && git commit -qm allowlisted >/dev/null )
|
|
215
|
+
check "LOW token in an allowlisted file → PASS " "$R" pass \
|
|
216
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
217
|
+
rm -rf "$R"
|
|
218
|
+
|
|
219
|
+
# ── Pair 9-b: the same LOW token in a file that is NOT allowlisted must still block. ──
|
|
220
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
221
|
+
( cd "$R" && printf 'mentions zzsynthoperator\n' > notes.md \
|
|
222
|
+
&& printf 'LOW\tzzsynthoperator\n' > .claude/rules/.public-surface-patterns \
|
|
223
|
+
&& git add -A && git commit -qm notallowlisted >/dev/null )
|
|
224
|
+
check "LOW token in a NON-allowlisted file → BLOCK" "$R" block \
|
|
225
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
226
|
+
rm -rf "$R"
|
|
227
|
+
|
|
228
|
+
# ── Pair 10: stacked-branch ADVISORY. Unlike every pair above, the assertion is on the WARNING,
|
|
229
|
+
# not on the verdict — this leg exists precisely because the advisory must never change the exit
|
|
230
|
+
# code. Origin (2026-07-27, field harness PRs #38/#39): a branch cut off a feature branch produced a
|
|
231
|
+
# child PR carrying the parent's three commits. Both ways out bill at parent-merge time — keeping the
|
|
232
|
+
# integration base leaves the child CONFLICTING after the parent is squashed, and retargeting onto the
|
|
233
|
+
# parent branch makes --delete-branch CLOSE the child. The observed run hit both (state=CLOSED,
|
|
234
|
+
# mergeable=CONFLICTING), and the tempting recovery is a force-push — the irreversible surface this
|
|
235
|
+
# hook guards.
|
|
236
|
+
#
|
|
237
|
+
# Assertions key on the STABLE MARKER `[fh-advisory:stacked-branch]`, not on the human prose around
|
|
238
|
+
# it (Wave-1 B): a prose-coupled assertion silently desyncs the moment the message is reworded. ──
|
|
239
|
+
SB_MARK='[fh-advisory:stacked-branch]'
|
|
240
|
+
sb_check() { # sb_check <name> <repo> <expect: warn|skip|none> <refline> [must-name]
|
|
241
|
+
# [must-name]: a branch the warning MUST name. "a warning appeared" is too weak an assertion —
|
|
242
|
+
# under a mutation that broke the self-exclusion, the advisory still warned, but about the
|
|
243
|
+
# pushed branch ITSELF. The leg went green while the defect was live (measured 2026-07-27).
|
|
244
|
+
local name="$1" repo="$2" expect="$3" refline="$4" mustname="${5:-}" out rc got
|
|
245
|
+
out=$(printf '%s\n' "$refline" | ( cd "$repo" && bash templates/.git-hooks/pre-push origin git@example:x/y.git 2>&1 )); rc=$?
|
|
246
|
+
if printf '%s' "$out" | grep -qiE 'bad substitution|unbound variable|syntax error|command not found'; then
|
|
247
|
+
echo " ❌ $name — RUNTIME FAULT in the hook"; FAILED=1; return
|
|
248
|
+
fi
|
|
249
|
+
if printf '%s' "$out" | grep -qF "$SB_MARK"; then
|
|
250
|
+
# Key on a machine token, not prose. Measured 2026-07-27: this classifier first grepped the
|
|
251
|
+
# phrase "skipped —", and a one-word rewording of the hook's message silently reclassified a
|
|
252
|
+
# correct SKIP as a WARN — the same prose-coupling defect Wave-1 flagged in the leg assertions.
|
|
253
|
+
if printf '%s' "$out" | grep -qF "$SB_MARK SKIPPED"; then got=skip; else got=warn; fi
|
|
254
|
+
else
|
|
255
|
+
got=none
|
|
256
|
+
fi
|
|
257
|
+
if [ "$got" != "$expect" ]; then
|
|
258
|
+
echo " ❌ $name — expected $expect, got $got"
|
|
259
|
+
printf '%s\n' "$out" | sed 's/^/ | /' | head -8
|
|
260
|
+
FAILED=1; return
|
|
261
|
+
fi
|
|
262
|
+
# The advisory must not move the verdict. These legs are otherwise-clean pushes, so a non-zero
|
|
263
|
+
# exit means the advisory (or something it perturbed) started blocking.
|
|
264
|
+
if [ "$rc" -ne 0 ]; then
|
|
265
|
+
echo " ❌ $name — advisory changed the verdict (exit $rc); it must be advisory only"
|
|
266
|
+
printf '%s\n' "$out" | sed 's/^/ | /' | head -6
|
|
267
|
+
FAILED=1; return
|
|
268
|
+
fi
|
|
269
|
+
if [ -n "$mustname" ] && ! printf '%s' "$out" | grep -qF "$mustname"; then
|
|
270
|
+
echo " ❌ $name — warned, but never named '$mustname' (warning for the wrong reason)"
|
|
271
|
+
printf '%s\n' "$out" | grep -F "$SB_MARK" -A5 | sed 's/^/ | /' | head -8
|
|
272
|
+
FAILED=1; return
|
|
273
|
+
fi
|
|
274
|
+
echo " ✅ $name ($expect, verdict unchanged${mustname:+, named $mustname})"
|
|
275
|
+
}
|
|
276
|
+
# ⚠️ 계기 주의 (실측): 처음엔 remote_sha 를 all-zero(=신규 브랜치)로 줬는데, 그러면 push 범위에
|
|
277
|
+
# 샌드박스의 base 커밋(= templates/.git-hooks/pre-push 사본을 담고 있다)이 들어가 이 훅의 **기존**
|
|
278
|
+
# load-bearing cross-family 가드가 발화해 차단됐다. sb_check 은 그 rc!=0 을 "advisory 가 verdict 를
|
|
279
|
+
# 바꿨다"로 오귀속했다 — 다른 가드의 차단을 이 기능 탓으로 읽는 계기 결함이다.
|
|
280
|
+
# base 커밋을 remote_sha 로 주면 범위가 픽스처 커밋만으로 좁혀져 그 혼선이 사라진다.
|
|
281
|
+
|
|
282
|
+
# 10-a known-positive: cut off a REMOTE feature branch.
|
|
283
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
284
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
285
|
+
&& git switch -q -c feat/parent && printf 'p\n' > p.md && git add p.md && git commit -qm parent >/dev/null \
|
|
286
|
+
&& git update-ref refs/remotes/origin/feat/parent "$(git rev-parse HEAD)" \
|
|
287
|
+
&& git switch -q -c feat/child && printf 'c\n' > c.md && git add c.md && git commit -qm child >/dev/null )
|
|
288
|
+
sb_check "cut off a REMOTE feature branch → warn" "$R" warn \
|
|
289
|
+
"refs/heads/feat/child $(cd "$R" && git rev-parse feat/child) refs/heads/feat/child $B"
|
|
290
|
+
rm -rf "$R"
|
|
291
|
+
|
|
292
|
+
# 10-b known-positive (Wave-1 A): the parent was never pushed. A remote-only check misses exactly
|
|
293
|
+
# the most likely moment for this mistake — before the parent's first push.
|
|
294
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
295
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
296
|
+
&& git switch -q -c feat/localparent && printf 'p\n' > p.md && git add p.md && git commit -qm parent >/dev/null \
|
|
297
|
+
&& git switch -q -c feat/child2 && printf 'c\n' > c.md && git add c.md && git commit -qm child >/dev/null )
|
|
298
|
+
sb_check "cut off a LOCAL-only feature branch → warn" "$R" warn \
|
|
299
|
+
"refs/heads/feat/child2 $(cd "$R" && git rev-parse feat/child2) refs/heads/feat/child2 $B"
|
|
300
|
+
rm -rf "$R"
|
|
301
|
+
|
|
302
|
+
# 10-c known-positive (Wave-1 S, live-reproduced): a branch name carrying a regex metacharacter.
|
|
303
|
+
# The first draft interpolated the name into `grep -vE ".../${self}$"`, so `feat/a.b`'s `.` also
|
|
304
|
+
# matched a genuinely different branch `feat/aXb` — grep -v dropped the REAL hit and the advisory
|
|
305
|
+
# silently no-opped on a true positive. Exclusion is exact-name now; this leg pins that.
|
|
306
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
307
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
308
|
+
&& git switch -q -c feat/aXb && printf 'p\n' > p.md && git add p.md && git commit -qm parent >/dev/null \
|
|
309
|
+
&& git update-ref refs/remotes/origin/feat/aXb "$(git rev-parse HEAD)" \
|
|
310
|
+
&& git switch -q -c 'feat/a.b' \
|
|
311
|
+
&& git branch -q -D feat/aXb )
|
|
312
|
+
# ⚠️ the local `feat/aXb` is DELETED on purpose. Left in place, the local listing line
|
|
313
|
+
# (`local feat/aXb`, no `/` before `feat`) dodges the vulnerable pattern `/feat/a.b$` and the leg
|
|
314
|
+
# goes green even with the bug restored — i.e. it would pass for the wrong reason. Measured: the
|
|
315
|
+
# first version of this fixture did exactly that under a mutation test.
|
|
316
|
+
sb_check "regex-metachar branch name → warn" "$R" warn \
|
|
317
|
+
"refs/heads/feat/a.b $(cd "$R" && git rev-parse 'feat/a.b') refs/heads/feat/a.b $B" \
|
|
318
|
+
"origin/feat/aXb"
|
|
319
|
+
rm -rf "$R"
|
|
320
|
+
|
|
321
|
+
# 10-d (Wave-1 A): base unresolvable → must SAY it did not scan. A silent return is
|
|
322
|
+
# indistinguishable from "scanned, nothing found" — 부재는 통과가 아니다.
|
|
323
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
324
|
+
( cd "$R" && git switch -q -c feat/nobase && printf 'n\n' > n.md && git add n.md && git commit -qm x >/dev/null )
|
|
325
|
+
sb_check "base unresolvable → skip announced" "$R" skip \
|
|
326
|
+
"refs/heads/feat/nobase $(cd "$R" && git rev-parse feat/nobase) refs/heads/feat/nobase $B"
|
|
327
|
+
rm -rf "$R"
|
|
328
|
+
|
|
329
|
+
# 10-e (cross-family LOW-7): parser edge cases. The first draft parsed human `git branch -r`
|
|
330
|
+
# output, where `origin/HEAD -> origin/main` parses as ref="HEAD" and a branch name with a space is
|
|
331
|
+
# truncated. for-each-ref emits refs, not a listing — this leg pins that the symbolic HEAD ref does
|
|
332
|
+
# not manufacture a warning and does not mask a real one.
|
|
333
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
334
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
335
|
+
&& git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main \
|
|
336
|
+
&& git switch -q -c feat/parent2 && printf 'p\n' > p.md && git add p.md && git commit -qm parent >/dev/null \
|
|
337
|
+
&& git update-ref refs/remotes/origin/feat/parent2 "$(git rev-parse HEAD)" \
|
|
338
|
+
&& git switch -q -c feat/child3 && printf 'c\n' > c.md && git add c.md && git commit -qm child >/dev/null )
|
|
339
|
+
sb_check "origin/HEAD symref present → warn" "$R" warn \
|
|
340
|
+
"refs/heads/feat/child3 $(cd "$R" && git rev-parse feat/child3) refs/heads/feat/child3 $B" \
|
|
341
|
+
"origin/feat/parent2"
|
|
342
|
+
rm -rf "$R"
|
|
343
|
+
|
|
344
|
+
# 10-f (cross-family MED-2): the integration branch is excluded by its RESOLVED name, not by a
|
|
345
|
+
# hard-coded main/master. A stack built on a LOCAL branch merely named `master` in a repo whose base
|
|
346
|
+
# is origin/main must still warn — hard-coding hid exactly that case.
|
|
347
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
348
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
349
|
+
&& git switch -q -c master && printf 'p\n' > p.md && git add p.md && git commit -qm onmaster >/dev/null \
|
|
350
|
+
&& git switch -q -c feat/child4 && printf 'c\n' > c.md && git add c.md && git commit -qm child >/dev/null )
|
|
351
|
+
sb_check "stack on a local 'master' branch → warn" "$R" warn \
|
|
352
|
+
"refs/heads/feat/child4 $(cd "$R" && git rev-parse feat/child4) refs/heads/feat/child4 $B" \
|
|
353
|
+
"master"
|
|
354
|
+
rm -rf "$R"
|
|
355
|
+
|
|
356
|
+
# 10-g CONTROL: cut off the integration branch. Without this leg an advisory that fired
|
|
357
|
+
# unconditionally would score green on every positive leg above while being useless.
|
|
358
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
359
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
360
|
+
&& git switch -q -c feat/clean && printf 'w\n' > w.md && git add w.md && git commit -qm clean >/dev/null )
|
|
361
|
+
sb_check "control: cut off main → no warn" "$R" none \
|
|
362
|
+
"refs/heads/feat/clean $(cd "$R" && git rev-parse feat/clean) refs/heads/feat/clean $B"
|
|
363
|
+
rm -rf "$R"
|
|
364
|
+
|
|
365
|
+
echo
|
|
366
|
+
if [ "$FAILED" -eq 0 ]; then
|
|
367
|
+
echo "[prepush-guard] ✅ all known pairs hold"
|
|
368
|
+
exit 0
|
|
369
|
+
fi
|
|
370
|
+
echo "[prepush-guard] ❌ BLOCKED — a known pair broke."
|
|
371
|
+
echo " BLOCK→PASS = the publish boundary stopped covering something it claims to cover."
|
|
372
|
+
echo " PASS→BLOCK = over-blocking, which trains PUBLIC_SURFACE_OK into reflex and disarms the gate."
|
|
373
|
+
echo " RUNTIME FAULT = the hook aborted; that is never a pass, however it exited."
|
|
374
|
+
exit 1
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# psa_scan_lib.sh — the ONE implementation of public-surface pattern loading and matching.
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS FILE EXISTS
|
|
5
|
+
# Three copies of this logic existed (pre-commit, pre-push, public_surface_scan_files.sh). Across a
|
|
6
|
+
# single 2026-07-26 cross-family audit, EVERY confidentiality defect found was a divergence between
|
|
7
|
+
# them, not a flaw in the idea:
|
|
8
|
+
# • the publish copy checked that the override was readable and non-empty; the commit copy did not
|
|
9
|
+
# • the commit copy failed closed when no patterns loaded; the publish copy did too; the push copy
|
|
10
|
+
# had to be told separately
|
|
11
|
+
# • a row with a SPACE instead of a TAB failed closed in two copies and was silently skipped in the third
|
|
12
|
+
# • `head -1` per line (a placeholder shielding a real token) was fixed in one copy and not the others
|
|
13
|
+
# • the LOW-severity file allowlist existed in one copy only — which is how the push gate blocked
|
|
14
|
+
# its own first real push
|
|
15
|
+
# Each was repaired where it was found. The prescription this repo already had recorded for that
|
|
16
|
+
# pattern — [[feedback_divergent_leniency_duplicate_normalizers]]: single source, visible drops,
|
|
17
|
+
# fail-closed — was quoted during that session and then not applied; the duplication was repaired
|
|
18
|
+
# three times instead of removed. This file removes it.
|
|
19
|
+
#
|
|
20
|
+
# WHAT IS SHARED vs WHAT STAYS WITH THE CALLER — the split is deliberate, do not "unify" further:
|
|
21
|
+
# SHARED (here): loading the two pattern layers, validating rows, and deciding whether a given
|
|
22
|
+
# token on a given path is a reportable hit.
|
|
23
|
+
# CALLER'S: WHICH content is scanned (staged diff / pushed range / packed file set), and the
|
|
24
|
+
# DEGRADE DIRECTION when the instrument is incomplete. Those genuinely differ:
|
|
25
|
+
# a commit is local and re-committable, so an absent operator override warns; a push
|
|
26
|
+
# and a publish are the acts that make content public, so the same state blocks.
|
|
27
|
+
# Collapsing that would either block every fresh clone's first commit (training the
|
|
28
|
+
# override into reflex) or let a real leak reach a public remote. This library
|
|
29
|
+
# therefore REPORTS state and never exits.
|
|
30
|
+
#
|
|
31
|
+
# Usage:
|
|
32
|
+
# . "$REPO_ROOT/scripts/psa_scan_lib.sh"
|
|
33
|
+
# psa_load "$defaults_path" "$override_path" # sets PSA_STREAM + the three state flags below
|
|
34
|
+
# psa_scan_tagged <<< "$path<TAB>line"… # prints hits, returns 1 if any hit was reported
|
|
35
|
+
#
|
|
36
|
+
# State set by psa_load (read-only for callers):
|
|
37
|
+
# PSA_STREAM validated rows only (invalid/malformed rows are dropped AND counted)
|
|
38
|
+
# PSA_DEFAULTS_OK 1 = committed defaults present, readable, non-empty
|
|
39
|
+
# PSA_OVERRIDE_PRESENT 1 = operator override present, readable, non-empty
|
|
40
|
+
# PSA_BAD_ROWS count of rows dropped as unusable (uncompilable regex, or no TAB)
|
|
41
|
+
# A caller that treats PSA_BAD_ROWS>0 or PSA_DEFAULTS_OK=0 as "clean" has a hole: those states mean
|
|
42
|
+
# the instrument is incomplete, and an incomplete instrument cannot certify anything.
|
|
43
|
+
|
|
44
|
+
# Placeholder shapes that must never count as a leak. Anchored whole-token: a substring match here
|
|
45
|
+
# would let a real token pass by merely CONTAINING a placeholder word.
|
|
46
|
+
PSA_PLACEHOLDER='^(<[a-z0-9_-]+>|\{[a-z_]+\}|EXAMPLE|dummy|changeme|REDACTED|xxxx|/Users/(EXAMPLE|yourname|\{[a-z_]+\}|<[a-z0-9_-]+>)/|AKIAIOSFODNN7EXAMPLE)$'
|
|
47
|
+
|
|
48
|
+
# Files that name wiring/companion tokens as part of doing their job. LOW severity only — HIGH/MED
|
|
49
|
+
# block everywhere, including here. Kept as a function so the list has exactly one definition.
|
|
50
|
+
psa_low_allowlisted() {
|
|
51
|
+
case "$1" in
|
|
52
|
+
.gitignore|scripts/sync-to-be.sh|.claude/rules/local_fh_context.md|templates/local_fh_context.md|templates/.claude/rules/*|templates/.git-hooks/*) return 0 ;;
|
|
53
|
+
*) return 1 ;;
|
|
54
|
+
esac
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# psa_load <defaults_path> <override_path>
|
|
58
|
+
# Prints diagnostics for every degraded state; sets the flags above. NEVER exits — the caller owns
|
|
59
|
+
# the degrade direction, because it differs by surface (see the header).
|
|
60
|
+
psa_load() {
|
|
61
|
+
local defaults="$1" override="$2" raw ov
|
|
62
|
+
PSA_STREAM=""; PSA_DEFAULTS_OK=0; PSA_OVERRIDE_PRESENT=0; PSA_BAD_ROWS=0
|
|
63
|
+
|
|
64
|
+
# Layer 1 — committed defaults. This file ships with the repo, so missing/unreadable/empty is a
|
|
65
|
+
# BROKEN INSTRUMENT, not a configuration choice. A valid override used to mask its absence, and the
|
|
66
|
+
# scan then reported a clean "full pattern set" with every universal pattern silently gone.
|
|
67
|
+
if [ -f "$defaults" ] && [ -r "$defaults" ]; then
|
|
68
|
+
raw=$(cat "$defaults" 2>/dev/null || true)
|
|
69
|
+
if [ -n "$(printf '%s' "$raw" | grep -vE '^[[:space:]]*(#|$)' || true)" ]; then
|
|
70
|
+
PSA_STREAM="$raw"; PSA_DEFAULTS_OK=1
|
|
71
|
+
fi
|
|
72
|
+
fi
|
|
73
|
+
[ "$PSA_DEFAULTS_OK" -eq 0 ] && \
|
|
74
|
+
echo " ❌ committed pattern defaults missing/unreadable/empty — universal patterns NOT loaded ($defaults)"
|
|
75
|
+
|
|
76
|
+
# Layer 2 — the gitignored operator literals. Present ONLY when a readable, non-empty read actually
|
|
77
|
+
# contributed patterns: an empty file must not masquerade as a configured gate.
|
|
78
|
+
if [ -f "$override" ]; then
|
|
79
|
+
if [ -r "$override" ]; then
|
|
80
|
+
ov=$(cat "$override" 2>/dev/null || true)
|
|
81
|
+
if [ -n "$(printf '%s' "$ov" | grep -vE '^[[:space:]]*(#|$)' || true)" ]; then
|
|
82
|
+
PSA_STREAM="$PSA_STREAM
|
|
83
|
+
$ov"; PSA_OVERRIDE_PRESENT=1
|
|
84
|
+
else
|
|
85
|
+
echo " ⚠️ operator pattern override is EMPTY — treated as absent."
|
|
86
|
+
fi
|
|
87
|
+
else
|
|
88
|
+
echo " ⚠️ operator pattern override exists but is UNREADABLE — treated as absent."
|
|
89
|
+
fi
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
# Row validation. Two ways a row can be present and still protect nothing, both previously silent:
|
|
93
|
+
# • no TAB → the whole line lands in the severity field and no detector is defined
|
|
94
|
+
# • bad ERE → grep exits >=2 and the error is swallowed downstream as a no-match
|
|
95
|
+
# Both are DROPPED and COUNTED here so the caller can fail closed on an incomplete instrument.
|
|
96
|
+
local valid="" line re rc
|
|
97
|
+
while IFS= read -r line; do
|
|
98
|
+
line="${line%$'\r'}" # CRLF: a trailing CR welds onto the regex and it matches nothing
|
|
99
|
+
case "$line" in ''|\#*) continue;; esac
|
|
100
|
+
re="${line#*$'\t'}"
|
|
101
|
+
if [ "$re" = "$line" ]; then
|
|
102
|
+
echo " ❌ unusable pattern row (no TAB between severity and regex): ${line%%$'\t'*}"
|
|
103
|
+
PSA_BAD_ROWS=$((PSA_BAD_ROWS+1)); continue
|
|
104
|
+
fi
|
|
105
|
+
printf '' | grep -E "$re" >/dev/null 2>&1; rc=$? # 0/1 = compiled; >=2 = regex error
|
|
106
|
+
if [ "$rc" -ge 2 ]; then
|
|
107
|
+
echo " ❌ unusable pattern (invalid regex) — this detector would match nothing: [${line%%$'\t'*}]"
|
|
108
|
+
PSA_BAD_ROWS=$((PSA_BAD_ROWS+1)); continue
|
|
109
|
+
fi
|
|
110
|
+
valid="$valid
|
|
111
|
+
$line"
|
|
112
|
+
done <<PSA_ROWS
|
|
113
|
+
$PSA_STREAM
|
|
114
|
+
PSA_ROWS
|
|
115
|
+
PSA_STREAM="$valid"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# psa_scan_tagged — reads "path<TAB>content" lines on stdin, prints one line per reportable hit.
|
|
119
|
+
# Returns 0 = nothing reportable, 1 = at least one hit.
|
|
120
|
+
#
|
|
121
|
+
# Why path-tagged input: the LOW allowlist is per FILE, so a scanner that flattens content loses the
|
|
122
|
+
# only information needed to apply it. The push copy did exactly that and blocked its own first real
|
|
123
|
+
# push on a companion-store name inside the script whose job is to sync to the companion store.
|
|
124
|
+
psa_scan_tagged() {
|
|
125
|
+
local input hit=0 row sev re path body tok
|
|
126
|
+
input=$(cat)
|
|
127
|
+
[ -n "$input" ] || return 0
|
|
128
|
+
while IFS= read -r row; do
|
|
129
|
+
case "$row" in ''|\#*) continue;; esac
|
|
130
|
+
re="${row#*$'\t'}"; [ "$re" = "$row" ] && continue
|
|
131
|
+
sev="${row%%$'\t'*}"
|
|
132
|
+
while IFS= read -r line; do
|
|
133
|
+
[ -z "$line" ] && continue
|
|
134
|
+
path="${line%%$'\t'*}"; body="${line#*$'\t'}"
|
|
135
|
+
# EVERY match on the line. Taking only the first let a documented placeholder earlier on the
|
|
136
|
+
# line shield a real token later on it.
|
|
137
|
+
while IFS= read -r tok; do
|
|
138
|
+
[ -z "$tok" ] && continue
|
|
139
|
+
printf '%s' "$tok" | grep -qiE "$PSA_PLACEHOLDER" && continue
|
|
140
|
+
if [ "$sev" = "LOW" ] && psa_low_allowlisted "$path"; then continue; fi
|
|
141
|
+
echo " ❌ $sev leak — ${path}: '$tok'"
|
|
142
|
+
hit=1
|
|
143
|
+
done <<PSA_TOK
|
|
144
|
+
$(printf '%s' "$body" | grep -oiE "$re" 2>/dev/null || true)
|
|
145
|
+
PSA_TOK
|
|
146
|
+
done <<PSA_LINES
|
|
147
|
+
$(printf '%s\n' "$input" | grep -iE "$re" 2>/dev/null || true)
|
|
148
|
+
PSA_LINES
|
|
149
|
+
done <<PSA_PAT
|
|
150
|
+
$PSA_STREAM
|
|
151
|
+
PSA_PAT
|
|
152
|
+
return $hit
|
|
153
|
+
}
|