@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.
Files changed (31) hide show
  1. package/.claude/rules/.public-surface-patterns.defaults +44 -0
  2. package/.claude/rules/fh_4axis_gate.md +207 -0
  3. package/.claude-plugin/marketplace.json +2 -2
  4. package/AGENTS.md +26 -2
  5. package/CATALOG.md +31 -0
  6. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
  7. package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
  8. package/package.json +21 -1
  9. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  10. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  11. package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
  12. package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
  13. package/scripts/chamber_candidate_collect.sh +223 -0
  14. package/scripts/degrade_direction_scan.sh +222 -0
  15. package/scripts/fh_session_load.sh +202 -0
  16. package/scripts/gate_pathspec_check.sh +166 -0
  17. package/scripts/prepush_guard_check.sh +374 -0
  18. package/scripts/psa_scan_lib.sh +153 -0
  19. package/scripts/public_surface_scan_files.sh +157 -0
  20. package/scripts/selfcheck.sh +16 -0
  21. package/scripts/session_close_check.sh +171 -0
  22. package/scripts/test_degrade_scan_shell_probes.sh +185 -0
  23. package/scripts/test_prepush_stdin_integrity.sh +119 -0
  24. package/scripts/universal_guard_check.sh +280 -0
  25. package/templates/.claude/rules/mcp_tool_gating.md +157 -0
  26. package/templates/.git-hooks/pre-commit +848 -0
  27. package/templates/.git-hooks/pre-push +585 -0
  28. package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
  29. package/templates/degrade_direction_scan.sh +222 -0
  30. package/templates/predelete_check.sh +72 -0
  31. 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
+ }