@chrono-meta/fh-gate 1.4.71 → 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 (38) 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 +59 -0
  6. package/README.ja.md +1 -1
  7. package/README.ko.md +1 -1
  8. package/README.md +1 -1
  9. package/README.zh.md +1 -1
  10. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
  11. package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
  12. package/package.json +21 -1
  13. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  14. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  15. package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
  16. package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
  17. package/plugins/fh-meta/skills/salience-splitter/SKILL.md +1 -1
  18. package/scripts/chamber_candidate_collect.sh +223 -0
  19. package/scripts/degrade_direction_scan.sh +222 -0
  20. package/scripts/fh-gate.sh +76 -2
  21. package/scripts/fh_session_load.sh +202 -0
  22. package/scripts/gate_pathspec_check.sh +166 -0
  23. package/scripts/prepush_guard_check.sh +374 -0
  24. package/scripts/psa_scan_lib.sh +153 -0
  25. package/scripts/public_surface_scan_files.sh +157 -0
  26. package/scripts/selfcheck.sh +16 -0
  27. package/scripts/session_close_check.sh +171 -0
  28. package/scripts/test_degrade_scan_shell_probes.sh +185 -0
  29. package/scripts/test_fh_gate_regressions.sh +46 -2
  30. package/scripts/test_prepush_stdin_integrity.sh +119 -0
  31. package/scripts/universal_guard_check.sh +280 -0
  32. package/templates/.claude/rules/mcp_tool_gating.md +157 -0
  33. package/templates/.git-hooks/pre-commit +848 -0
  34. package/templates/.git-hooks/pre-push +585 -0
  35. package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
  36. package/templates/degrade_direction_scan.sh +222 -0
  37. package/templates/predelete_check.sh +72 -0
  38. package/templates/regression_guard.sh +563 -0
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env bash
2
+ # gate_pathspec_check.sh — known-pair regression anchor for gate PATH COVERAGE.
3
+ #
4
+ # WHY THIS EXISTS
5
+ # The gate-locality class has now recurred four times: scripts/ (2026-06-26), AGENTS.md
6
+ # inheritance (#111/#117), agent definitions (2026-06-27), and SKILL_detail.md (2026-07-26).
7
+ # Every instance had the same shape: an asset class the canonical rule *declared* covered, which
8
+ # the gate implementation's path term did not actually match — and the miss rendered as PASS,
9
+ # because "no file matched" and "all files passed" are indistinguishable downstream.
10
+ #
11
+ # The 07-26 instance was the sharpest: the term was the literal `SKILL\.md`, and the string
12
+ # `SKILL_detail.md` does not contain `SKILL.md` (the underscore breaks it). 17 files, 208,710 B,
13
+ # 27.7% of the skill-spec surface, 16 of 17 holding fenced code blocks — ungated. It leaked twice
14
+ # for real (371c04f, e661931: single-file edits to a GATE SKILL's own behavioral spec).
15
+ #
16
+ # So this is not a style check. It is the mechanical anchor for the fix, per the FH rule that a
17
+ # harness edit is a draft until a check fails when the mistake recurs.
18
+ #
19
+ # METHOD — known-pair, per CLAUDE.md §Instrument-Calibration. Every case asserts BOTH directions:
20
+ # a known-positive that MUST match and a known-negative that MUST NOT. A checker that only ever
21
+ # confirms positives cannot tell "covers everything" from "matches everything".
22
+ #
23
+ # Usage: bash scripts/gate_pathspec_check.sh # exit 0 = all pairs hold, 1 = a pair broke
24
+ set -uo pipefail
25
+
26
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
27
+ HOOK="$REPO_ROOT/templates/.git-hooks/pre-commit"
28
+ GUARD="$REPO_ROOT/templates/regression_guard.sh"
29
+
30
+ fail=0
31
+ pass=0
32
+
33
+ # Extract a live regex from the implementation instead of restating it here. A copy would drift
34
+ # from the thing it claims to verify — which is the very defect class this file exists to catch.
35
+ extract_term() { # $1 = file, $2 = variable-assignment marker
36
+ grep -A2 "^${2}=" "$1" 2>/dev/null | grep -oE '\| grep -E "[^"]+"' | head -1 \
37
+ | sed -E 's/^\| grep -E "//; s/"$//'
38
+ }
39
+
40
+ check() { # $1 = label, $2 = regex, $3 = should-match path, $4 = should-NOT-match path
41
+ local label="$1" re="$2" pos="$3" neg="$4" ok=1 rc
42
+ # grep exits 2 on a MALFORMED regex. `if ! grep -q` would negate that 2 into "true" and report
43
+ # it as "known-positive not covered" — fail-closed in direction, but it misnames the cause, and
44
+ # a checker that misreports why it failed sends the next reader to fix the wrong thing.
45
+ echo "$pos" | grep -qE "$re"; rc=$?
46
+ if [ "$rc" -eq 2 ]; then
47
+ echo " ❌ $label — extracted pattern is not a valid regex (instrument error, NOT a coverage result)"
48
+ echo " pattern: $re"
49
+ fail=$((fail + 1)); return
50
+ fi
51
+ [ "$rc" -ne 0 ] && { echo " ❌ $label — known-POSITIVE not covered: $pos"; ok=0; }
52
+ if echo "$neg" | grep -qE "$re"; then
53
+ echo " ❌ $label — known-NEGATIVE wrongly covered: $neg"; ok=0
54
+ fi
55
+ if [ "$ok" -eq 1 ]; then
56
+ echo " ✅ $label"; pass=$((pass + 1))
57
+ else
58
+ fail=$((fail + 1))
59
+ fi
60
+ }
61
+
62
+ echo "gate_pathspec_check — known-pair coverage anchors"
63
+ echo
64
+
65
+ # ── 1. pre-commit HEAVY term ──────────────────────────────────────────────────
66
+ HEAVY_RE="$(extract_term "$HOOK" HEAVY)"
67
+ if [ -z "$HEAVY_RE" ]; then
68
+ echo " ❌ could not extract HEAVY term from $HOOK — instrument error, NOT a pass"
69
+ exit 1
70
+ fi
71
+ # The 07-26 regression: detail files must be HEAVY. Negative: a tracks/ record must not be.
72
+ check "HEAVY covers SKILL_detail.md" "$HEAVY_RE" \
73
+ "plugins/fh-meta/skills/frontier-digest/SKILL_detail.md" \
74
+ "tracks/_meta/fh_signal_2026-07-26_ai.md"
75
+ check "HEAVY still covers SKILL.md" "$HEAVY_RE" \
76
+ "plugins/fh-meta/skills/frontier-digest/SKILL.md" \
77
+ "README.md"
78
+ # Prior gate-locality instances — anchored so a future edit cannot silently drop them.
79
+ check "HEAVY covers agent definitions (seam #3)" "$HEAVY_RE" \
80
+ "plugins/fh-meta/agents/challenger.md" \
81
+ "docs/README.md"
82
+ check "HEAVY covers scripts/*.sh (seam #1)" "$HEAVY_RE" \
83
+ "scripts/gate_pathspec_check.sh" \
84
+ "tracks/_audit/session_2026_07_26_agentsmith-sister.md"
85
+
86
+ # ── 2. regression_guard GUARD_PATHSPEC ────────────────────────────────────────
87
+ # Read the array as the guard itself defines it; match with the same glob semantics git uses.
88
+ # NOTE: no `mapfile` — macOS ships bash 3.2, where it does not exist. This is the documented
89
+ # bash-3.2 portability class; a 4.x-only builtin here would make the anchor itself the thing that
90
+ # breaks on the operator's own machine.
91
+ SPEC=()
92
+ while IFS= read -r line; do
93
+ [ -n "$line" ] && SPEC+=("$line")
94
+ done < <(sed -n '/^GUARD_PATHSPEC=(/,/^)/p' "$GUARD" | grep -oE "'[^']+'" | tr -d "'")
95
+ if [ "${#SPEC[@]:-0}" -eq 0 ]; then
96
+ echo " ❌ could not extract GUARD_PATHSPEC from $GUARD — instrument error, NOT a pass"
97
+ exit 1
98
+ fi
99
+ spec_matches() { # $1 = path
100
+ local p="$1" g
101
+ for g in "${SPEC[@]}"; do
102
+ # shellcheck disable=SC2254
103
+ case "$p" in $g) return 0 ;; esac
104
+ done
105
+ return 1
106
+ }
107
+ for pair in \
108
+ "plugins/fh-meta/skills/frontier-digest/SKILL_detail.md|tracks/_meta/x.md|PATHSPEC covers SKILL_detail.md" \
109
+ "plugins/fh-meta/skills/frontier-digest/SKILL.md|README.md|PATHSPEC still covers SKILL.md" \
110
+ "CLAUDE.md|CLAUDE.local.md|PATHSPEC covers CLAUDE.md but not the local override"
111
+ do
112
+ IFS='|' read -r pos neg label <<< "$pair"
113
+ ok=1
114
+ spec_matches "$pos" || { echo " ❌ $label — known-POSITIVE not covered: $pos"; ok=0; }
115
+ spec_matches "$neg" && { echo " ❌ $label — known-NEGATIVE wrongly covered: $neg"; ok=0; }
116
+ if [ "$ok" -eq 1 ]; then echo " ✅ $label"; pass=$((pass + 1)); else fail=$((fail + 1)); fi
117
+ done
118
+
119
+ # ── 3. Canonical-vs-implementation parity ─────────────────────────────────────
120
+ # regression_guard.sh's own comment: "두 목록이 갈리면 갈린 쪽이 조용히 무검사 구간이 된다."
121
+ # Anchor that warning mechanically for the asset class that just broke.
122
+ CANON="$REPO_ROOT/.claude/rules/fh_4axis_gate.md"
123
+ # Scope the match to the ASSET-LIST sentence, not the whole file. A bare whole-file grep would go
124
+ # green on a line that says "SKILL_detail.md is excluded" — i.e. it would certify parity against
125
+ # documentation that contradicts the code. Match the declaration line itself.
126
+ if grep -q 'Whenever the AI modifies FH assets.*SKILL_detail\.md' "$CANON" 2>/dev/null; then
127
+ echo " ✅ canonical rule declares SKILL_detail.md (in the asset-list line)"; pass=$((pass + 1))
128
+ else
129
+ echo " ❌ canonical rule ($CANON) no longer declares SKILL_detail.md — the two lists diverged,"
130
+ echo " which is exactly the silent no-check condition this anchor exists to prevent."
131
+ fail=$((fail + 1))
132
+ fi
133
+
134
+ # ── 4. Enumeration sweep — the anti-guessing check ────────────────────────────
135
+ # Every fix above answers "is THIS name covered?" — which only ever closes the names someone
136
+ # thought of. This one inverts it: enumerate what actually EXISTS under plugins/*/skills/ and
137
+ # assert the HEAVY term covers all of it. A new companion-file convention (SKILL_summary.md,
138
+ # a nested docs/ page, a deeper skill directory) then fails HERE, at introduction, instead of
139
+ # waiting for someone to notice the naming gap years later. Reality is the input, not a guess.
140
+ # (Adversarial credit: an Axis-2 sidecar pass argued the name-by-name fixes could not, in
141
+ # principle, close the class — correct, and this is the answer to it.)
142
+ uncovered=""
143
+ while IFS= read -r f; do
144
+ [ -z "$f" ] && continue
145
+ echo "$f" | grep -qE "$HEAVY_RE" || uncovered="$uncovered$f
146
+ "
147
+ done < <(cd "$REPO_ROOT" && find plugins -path '*/skills/*' -name '*.md' -type f 2>/dev/null | sort)
148
+ if [ -z "$uncovered" ]; then
149
+ echo " ✅ enumeration: every .md under plugins/*/skills/ is covered by the HEAVY term"
150
+ pass=$((pass + 1))
151
+ else
152
+ echo " ❌ enumeration: files exist under plugins/*/skills/ that NO gate term covers —"
153
+ printf '%s' "$uncovered" | sed 's/^/ /'
154
+ echo " Either widen the gate term, or state in fh_4axis_gate.md why this class is exempt."
155
+ fail=$((fail + 1))
156
+ fi
157
+
158
+ echo
159
+ if [ "$fail" -eq 0 ]; then
160
+ echo "gate_pathspec_check: PASS ($pass pairs)"
161
+ exit 0
162
+ fi
163
+ echo "gate_pathspec_check: FAIL ($fail broken, $pass ok)"
164
+ echo "A gate path term stopped covering an asset class it is declared to cover."
165
+ echo "Do NOT relax the anchor to make it green — fix the term, or retire the pair deliberately."
166
+ exit 1
@@ -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