@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,185 @@
1
+ #!/usr/bin/env bash
2
+ # test_degrade_scan_shell_probes.sh — regression anchor for the shell (S*) probes of
3
+ # scripts/degrade_direction_scan.sh.
4
+ #
5
+ # WHY THIS EXISTS (measured 2026-07-28, known-pair calibration):
6
+ # The scan COLLECTED `.sh` files but every probe was Python-shaped (`except:` / `.get(k, True)` /
7
+ # `if not x:` / `.split()`). A known-positive bash file carrying four distinct default-toward-PASS
8
+ # shapes scored 0/4 and the scan printed "no default-toward-PASS smells in 1 scanned py/sh file".
9
+ # That is a FALSE CLEAN — strictly worse than honest non-coverage, because a caller keying on the
10
+ # message or exit code reads it as verified.
11
+ # A second, larger hole surfaced in the same run: git hooks are named `pre-push` / `pre-commit`
12
+ # (no extension) and live under a DOTTED directory, so `templates/.git-hooks` — FH's own mechanical
13
+ # floor — reported "no scannable (py/sh) target files", exit 0.
14
+ #
15
+ # The assertions below pin: (1) the S-probes separate a known pair, (2) extensionless shell files
16
+ # under a dotted directory are collected, (3) the Python probes did not regress, and (4) two
17
+ # deliberate NON-detections stay non-detections — flagging them would push an author to delete a
18
+ # remedy or to silence a legitimate precondition guard.
19
+ #
20
+ # Usage: bash scripts/test_degrade_scan_shell_probes.sh
21
+ # Exit: 0 = all assertions pass; 1 = a regression.
22
+ set -uo pipefail
23
+
24
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
25
+ SCAN="$REPO_ROOT/scripts/degrade_direction_scan.sh"
26
+ [ -f "$SCAN" ] || { echo "FAIL: $SCAN not found"; exit 1; }
27
+
28
+ TMP="$(mktemp -d)"
29
+ trap 'rm -rf "$TMP"' EXIT
30
+
31
+ pass=0; fail=0
32
+ ok() { printf ' ✅ %s\n' "$1"; pass=$((pass+1)); }
33
+ bad() { printf ' ❌ %s\n' "$1"; fail=$((fail+1)); }
34
+
35
+ # Count S-probe hit lines. Deliberately counts the probe TAG, not the summary line: a summary can say
36
+ # "clean" for reasons unrelated to detection, and this anchor must not be satisfiable by prose.
37
+ s_hits() { bash "$SCAN" "$@" 2>&1 | grep -cE '\[S[0-9]:'; }
38
+ p_hits() { bash "$SCAN" "$@" 2>&1 | grep -cE '\[[A-F][0-9]?:'; }
39
+ rc_of() { bash "$SCAN" "$@" >/dev/null 2>&1; echo $?; }
40
+
41
+ # ── Fixtures ────────────────────────────────────────────────────────────────────────
42
+ # Known POSITIVE: four distinct shell-shaped default-toward-PASS constructs, one per probe.
43
+ cat > "$TMP/known_positive.sh" <<'EOF'
44
+ #!/usr/bin/env bash
45
+ check_secret() {
46
+ scan_output=$(run_scanner "$1") || return 0 # S1: the check errored -> report success
47
+ if [ -z "$scan_output" ]; then
48
+ return 0 # S4: empty == errored == "clean"
49
+ fi
50
+ return 1
51
+ }
52
+ verdict=$(get_verdict) || verdict="PASS" # S3: unresolved -> permissive verdict
53
+ if [ "$verdict" = "BLOCK" ]; then
54
+ exit 1
55
+ else
56
+ exit 0 # S2: unenumerated case -> allow
57
+ fi
58
+ EOF
59
+
60
+ # Known NEGATIVE: the same logic written fail-closed. Must stay silent, or the probes are noise.
61
+ cat > "$TMP/known_negative.sh" <<'EOF'
62
+ #!/usr/bin/env bash
63
+ set -euo pipefail
64
+ check_secret() {
65
+ if ! scan_output=$(run_scanner "$1"); then
66
+ echo "scanner failed - fail closed" >&2; return 1
67
+ fi
68
+ [ -n "$scan_output" ] && return 1
69
+ return 0
70
+ }
71
+ EOF
72
+
73
+ # Extensionless hook under a DOTTED directory — the collection bug's exact shape.
74
+ mkdir -p "$TMP/.git-hooks"
75
+ cat > "$TMP/.git-hooks/pre-push" <<'EOF'
76
+ #!/usr/bin/env bash
77
+ verdict=$(classify_refs) || verdict="ALLOW"
78
+ EOF
79
+
80
+ # Deliberate NON-detections.
81
+ cat > "$TMP/non_detections.sh" <<'EOF'
82
+ #!/usr/bin/env bash
83
+ # (a) integer sanitization — the PRESCRIBED remedy for the pipefail-fallback class, not the defect.
84
+ count=$(grep -c pattern file)
85
+ if [ "${count:-0}" -gt 0 ]; then echo "found"; fi
86
+ # (b) SCOPE guards — "this run does not apply here" is not a claim that a check passed.
87
+ [ -d "$HOME/projects" ] || exit 0
88
+ [[ "$1" =~ ^[0-9]{4}$ ]] || return 0
89
+ EOF
90
+
91
+ # DEPENDENCY guards must NOT be swept up by the scope-guard exclusion above. `[ -f lib ] || exit 0`
92
+ # says "my guard library is missing, therefore allow" — the fail-open shape measured on qasp
93
+ # 2026-07-28. An earlier draft of the scoping hid it; this fixture pins the distinction.
94
+ cat > "$TMP/dependency_guards.sh" <<'EOF'
95
+ #!/usr/bin/env bash
96
+ [ -f "$GUARD_LIB" ] || exit 0
97
+ [ -x "$SCANNER" ] || exit 0
98
+ EOF
99
+
100
+ # Python known-pair — the pre-existing probes must not have regressed.
101
+ printf 'def f(x):\n try:\n return g(x)\n except Exception:\n return True\n' > "$TMP/kp.py"
102
+ printf 'def f(x):\n try:\n return g(x)\n except Exception:\n raise\n' > "$TMP/kn.py"
103
+
104
+ # ── Assertions ──────────────────────────────────────────────────────────────────────
105
+ echo "degrade-scan shell-probe regression anchor"
106
+
107
+ n=$(s_hits "$TMP/known_positive.sh")
108
+ [ "$n" -eq 4 ] && ok "known-positive .sh: 4/4 shell smells detected" \
109
+ || bad "known-positive .sh: expected 4 S-hits, got $n (probes blind to bash again)"
110
+
111
+ rc=$(rc_of "$TMP/known_positive.sh")
112
+ [ "$rc" -eq 2 ] && ok "known-positive .sh: advisory exit 2" \
113
+ || bad "known-positive .sh: expected exit 2, got $rc"
114
+
115
+ n=$(s_hits "$TMP/known_negative.sh")
116
+ [ "$n" -eq 0 ] && ok "known-negative .sh: silent (probes discriminate, not just fire)" \
117
+ || bad "known-negative .sh: expected 0 S-hits, got $n"
118
+
119
+ rc=$(rc_of "$TMP/known_negative.sh")
120
+ [ "$rc" -eq 0 ] && ok "known-negative .sh: exit 0" \
121
+ || bad "known-negative .sh: expected exit 0, got $rc"
122
+
123
+ # Directory walk must reach an extensionless shell file inside a dotted directory.
124
+ n=$(s_hits "$TMP/.git-hooks")
125
+ [ "$n" -ge 1 ] && ok "extensionless hook under a dotted dir: collected and scanned" \
126
+ || bad "extensionless hook under a dotted dir: not scanned ($n hits) — the git-hook floor is invisible again"
127
+
128
+ # ...and so must an explicit file argument naming it.
129
+ n=$(s_hits "$TMP/.git-hooks/pre-push")
130
+ [ "$n" -ge 1 ] && ok "extensionless hook as a direct file argument: scanned" \
131
+ || bad "extensionless hook as a direct file argument: not scanned ($n hits)"
132
+
133
+ n=$(s_hits "$TMP/non_detections.sh")
134
+ [ "$n" -eq 0 ] && ok "non-detections stay silent (\${v:-0} sanitization + precondition guards)" \
135
+ || bad "non-detections fired $n time(s) — flagging the remedy trains authors to delete it"
136
+
137
+ # A DOTTED shell filename (`helper.bash`) must not be silently dropped from a directory walk.
138
+ # Cross-family finding (gpt-5.5, 2026-07-28), reproduced before acceptance: the directory path
139
+ # dropped it in silence while the explicit-file path reported the same file as UNSCANNABLE.
140
+ # Silent on one path, honest on the other, is the fail-open half.
141
+ mkdir -p "$TMP/dotted"
142
+ cat > "$TMP/dotted/helper.bash" <<'EOF'
143
+ #!/usr/bin/env bash
144
+ scan=$(run_scanner "$1") || return 0
145
+ EOF
146
+ cat > "$TMP/dotted/gate.sh" <<'EOF'
147
+ #!/usr/bin/env bash
148
+ scan=$(run_scanner "$1") || return 0
149
+ EOF
150
+ n=$(s_hits "$TMP/dotted")
151
+ [ "$n" -eq 2 ] && ok "dotted shell filename (helper.bash) scanned alongside gate.sh in a directory walk" \
152
+ || bad "dotted shell filename: expected 2 S-hits, got $n — a .bash/.zsh gate is silently dropped again"
153
+
154
+ n=$(s_hits "$TMP/dependency_guards.sh")
155
+ [ "$n" -eq 2 ] && ok "dependency guards (\`[ -f lib ] || exit 0\`) still detected — scope exclusion did not swallow them" \
156
+ || bad "dependency guards: expected 2 S-hits, got $n — 'guard library missing → allow' is hidden again"
157
+
158
+ n=$(p_hits "$TMP/kp.py")
159
+ [ "$n" -ge 1 ] && ok "python known-positive: pre-existing probes still fire" \
160
+ || bad "python known-positive: no hits — the Python probes regressed"
161
+
162
+ n=$(p_hits "$TMP/kn.py")
163
+ [ "$n" -eq 0 ] && ok "python known-negative: still silent" \
164
+ || bad "python known-negative: $n hit(s) — Python probes became noisy"
165
+
166
+ # The field-propagated copy must not drift from the canonical one. Two copies of the same
167
+ # normalizer diverge, and the lenient half silently drops what the strict half catches — measured
168
+ # 2026-07-28: `templates/` was 2 lines behind BEFORE this session's fix and then a full 8 KB behind
169
+ # after it, so field harnesses (the ones the cross-family gate doc actually points at) were running
170
+ # the version that scored 0/4 on the known-positive while `scripts/` scored 4/4.
171
+ TPL="$REPO_ROOT/templates/degrade_direction_scan.sh"
172
+ if [ ! -d "$REPO_ROOT/templates" ]; then
173
+ # Package mode: the npm tarball may ship a narrower surface. Absent templates/ is not drift.
174
+ printf ' \u2013 field-copy drift check SKIPPED (no templates/ — package mode)\n'
175
+ elif [ ! -f "$TPL" ]; then
176
+ bad "templates/ exists but degrade_direction_scan.sh is MISSING there — field harnesses get no scan"
177
+ elif cmp -s "$TPL" "$SCAN"; then
178
+ ok "field-propagated copy is byte-identical to scripts/ (no divergent-normalizer drift)"
179
+ else
180
+ bad "templates/degrade_direction_scan.sh has DRIFTED from scripts/ — the field copy is what qasp/pmh run; sync it (cp scripts/degrade_direction_scan.sh templates/)"
181
+ fi
182
+
183
+ echo "----"
184
+ echo "degrade-scan shell probes: $pass passed, $fail failed"
185
+ [ "$fail" -eq 0 ] || exit 1
@@ -34,7 +34,7 @@ while [ $# -gt 0 ]; do
34
34
  esac
35
35
  done
36
36
  cat >/dev/null # consume the prompt on stdin
37
- [ -n "$out" ] && printf '%s' "$FAKE_PAYLOAD" > "$out"
37
+ [ -n "$out" ] && printf '%s' "${FAKE_PAYLOAD_CODEX:-$FAKE_PAYLOAD}" > "$out"
38
38
  exit 0
39
39
  FAKE
40
40
  chmod +x "$FAKEBIN/codex"
@@ -47,7 +47,7 @@ cat > "$FAKEBIN/claude" <<'FAKE'
47
47
  #!/usr/bin/env bash
48
48
  cat >/dev/null # consume the prompt on stdin
49
49
  if [ -n "${FAKE_ENVELOPE:-}" ]; then printf '%s\n' "$FAKE_ENVELOPE"; exit 0; fi
50
- printf '{"is_error":false,"subtype":"success","structured_output":%s}\n' "$FAKE_PAYLOAD"
50
+ printf '{"is_error":false,"subtype":"success","structured_output":%s}\n' "${FAKE_PAYLOAD_CLAUDE:-$FAKE_PAYLOAD}"
51
51
  exit 0
52
52
  FAKE
53
53
  chmod +x "$FAKEBIN/claude"
@@ -201,6 +201,50 @@ printf '#!/usr/bin/env bash\nexit 1\n' > "$NOENT/od"; chmod +x "$NOENT/od"
201
201
  check "no CSPRNG (openssl+od stubbed to fail) → fails closed" 10 \
202
202
  env PATH="$NOENT:$PATH" FH_DRY_RUN=1 bash "$GATE" "package.json" quick test
203
203
 
204
+ echo
205
+ echo "── FH_BACKEND=cross (decorrelated review) ──"
206
+ # cross runs BOTH families and UNIONs. `auto` is fallback SELECTION and runs one leg; conflating the
207
+ # two would let a single-family verdict read as decorrelated, which is the defect class this mode
208
+ # exists to remove. These pairs pin: the union verdict, the leg accounting, and — most importantly —
209
+ # that a degraded (single-leg) run says so in machine-readable form.
210
+ CROSS_PASS='{"status":"SUCCESS","verdict":"PASS","findings_count":0,"findings_a":0,"findings_b":0,"findings":[]}'
211
+ CROSS_BLOCK='{"status":"SUCCESS","verdict":"BLOCKED","findings_count":1,"findings_a":1,"findings_b":0,"findings":[{"grade":"A","location":"x:1","title":"t","evidence":"e","fix":"f"}]}'
212
+
213
+ check_out() { # <name> <expected-exit> <grep-ere that MUST appear> -- <cmd...>
214
+ local name="$1" expect="$2" want="$3"; shift 3
215
+ local got
216
+ "$@" >"$TMPROOT/out" 2>"$TMPROOT/err"; got=$?
217
+ if [ "$got" -eq "$expect" ] && grep -qE "$want" "$TMPROOT/out"; then
218
+ printf 'PASS %-58s (exit %s)\n' "$name" "$got"; pass=$((pass + 1))
219
+ else
220
+ printf 'FAIL %-58s expected %s + /%s/, got %s\n' "$name" "$expect" "$want" "$got"
221
+ sed 's/^/ /' "$TMPROOT/out" | head -4; sed 's/^/ /' "$TMPROOT/err" | head -2
222
+ fail=$((fail + 1))
223
+ fi
224
+ }
225
+ run_cross() { # <claude-payload> <codex-payload>
226
+ env PATH="$FAKEBIN:$PATH" FH_BACKEND=cross FH_MODEL=fake \
227
+ FAKE_PAYLOAD_CLAUDE="$1" FAKE_PAYLOAD_CODEX="$2" FAKE_PAYLOAD="$1" \
228
+ bash "$GATE" "package.json" quick test
229
+ }
230
+
231
+ check_out "cross: both PASS → PASS, decorrelated" 0 'FH_GATE_DECORRELATED: yes' \
232
+ run_cross "$CROSS_PASS" "$CROSS_PASS"
233
+ # UNION, not vote: one leg blocking is enough. A majority rule would discard precisely the finding
234
+ # only the other family saw, which is the entire point of running two.
235
+ check_out "cross: one leg BLOCKED → union BLOCKED" 2 'FH_GATE_VERDICT: BLOCKED' \
236
+ run_cross "$CROSS_PASS" "$CROSS_BLOCK"
237
+ check_out "cross: both legs' findings survive the union" 2 'FH_GATE_LEGS: claude,codex' \
238
+ run_cross "$CROSS_BLOCK" "$CROSS_BLOCK"
239
+ # A machine with only one family is the COMMON case, not an edge case. It must not be silent.
240
+ ONELEG="$TMPROOT/oneleg"; mkdir -p "$ONELEG"; cp "$FAKEBIN/claude" "$ONELEG/claude"
241
+ check_out "cross: codex absent → single leg, DECORRELATED: no" 0 'FH_GATE_DECORRELATED: no' \
242
+ env PATH="$ONELEG:/usr/bin:/bin" FH_BACKEND=cross FH_MODEL=fake \
243
+ FAKE_PAYLOAD_CLAUDE="$CROSS_PASS" FAKE_PAYLOAD="$CROSS_PASS" \
244
+ bash "$GATE" "package.json" quick test
245
+ check "cross: no family available → fails closed" 10 \
246
+ env PATH="/usr/bin:/bin" FH_BACKEND=cross bash "$GATE" "package.json" quick test
247
+
204
248
  echo
205
249
  echo "────────────────────────────────────────────────────────────────────"
206
250
  printf 'fh-gate regressions: %d passed, %d failed\n' "$pass" "$fail"
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bash
2
+ # test_prepush_stdin_integrity.sh — regression anchor for the 2026-07-20 fail-open hole.
3
+ #
4
+ # HOLE: the session-close block inserted at the TOP of templates/.git-hooks/pre-push starts a
5
+ # subprocess. git delivers the pushed ref list on the hook's STDIN, and the ref-reading loop runs
6
+ # AFTER that block. A stdin-inheriting subprocess drains the ref list → the loop sees zero refs →
7
+ # every DEL_/FORCED_ variable stays empty → the hook takes "nothing destructive → exit 0",
8
+ # silently disarming the Destructive-Op gate on a branch-delete / force push.
9
+ #
10
+ # This test reproduces the mechanism (not just greps for the fix), then asserts the fix is present.
11
+ set -uo pipefail
12
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
13
+ HOOK="$ROOT/templates/.git-hooks/pre-push"
14
+ FAILED=0
15
+ _ok(){ echo "PASS $1"; }
16
+ _no(){ echo "FAIL $1"; FAILED=1; }
17
+
18
+ # T1 — the mechanism is real: a stdin-inheriting subprocess eats the ref list.
19
+ GOT=$(printf 'r1 a1 r2 b2\n' | { _=$(bash -c 'cat >/dev/null' 2>&1); while read -r a _b _c _d; do echo "$a"; done; })
20
+ [ -z "$GOT" ] && _ok "T1 mechanism reproduces (inheriting subprocess drains stdin)" \
21
+ || _no "T1 mechanism did NOT reproduce — test is no longer meaningful, re-derive it"
22
+
23
+ # T2 — the fix works: redirecting the subprocess from /dev/null preserves the ref list.
24
+ GOT=$(printf 'r1 a1 r2 b2\n' | { _=$(bash -c 'cat >/dev/null' 2>&1 </dev/null); while read -r a _b _c _d; do echo "$a"; done; })
25
+ [ "$GOT" = "r1" ] && _ok "T2 '< /dev/null' preserves the ref list" \
26
+ || _no "T2 redirect did not preserve stdin (got: '$GOT')"
27
+
28
+ # T3 — the shipped hook actually carries the guard on the close-check invocation.
29
+ LINE=$(grep -n 'session_close_check\.sh' "$HOOK" | grep -v '^\s*#' | grep '_SC_OUT=' || true)
30
+ case "$LINE" in
31
+ *"< /dev/null"*|*"</dev/null"*) _ok "T3 pre-push close-check invocation is stdin-guarded" ;;
32
+ "") _no "T3 could not find the _SC_OUT invocation in $HOOK — hole may have been reintroduced under a new name" ;;
33
+ *) _no "T3 pre-push close-check invocation LACKS '< /dev/null' → Destructive-Op gate is fail-open: $LINE" ;;
34
+ esac
35
+
36
+ # T4 — STRUCTURAL FIX: ref classification must run BEFORE any helper subprocess.
37
+ # (Inverted 2026-07-20: the first draft asserted the opposite, because the block originally sat
38
+ # above the loop. A cross-family audit called that a priority inversion — advisory check above a
39
+ # blocking safety gate — so the block moved below classification and this assertion flipped with it.
40
+ # Match CODE lines only; an earlier draft matched the hook's own explanatory COMMENT and
41
+ # self-inverted. Instrument defect caught by the instrument.)
42
+ SC_LN=$(grep -n '_SC_OUT=' "$HOOK" | grep -v ':[[:space:]]*#' | head -1 | cut -d: -f1)
43
+ LOOP_LN=$(grep -n 'while read -r local_ref' "$HOOK" | grep -v ':[[:space:]]*#' | head -1 | cut -d: -f1)
44
+ if [ -n "$SC_LN" ] && [ -n "$LOOP_LN" ] && [ "$LOOP_LN" -lt "$SC_LN" ]; then
45
+ _ok "T4 ref classification (:$LOOP_LN) precedes the close-check helper (:$SC_LN)"
46
+ else
47
+ _no "T4 PRIORITY INVERSION — helper(:${SC_LN:-?}) runs at/before ref classification(:${LOOP_LN:-?}); a stdin-reading helper can disarm the Destructive-Op gate"
48
+ fi
49
+
50
+ # T5 — END-TO-END (the anchor a cross-family audit required): a helper that ACTUALLY reads stdin,
51
+ # plus a synthetic DESTRUCTIVE ref, must still BLOCK. This is what T1-T4 cannot prove on their own —
52
+ # they test the mechanism and the source layout; this tests the shipped hook's real behavior.
53
+ T5_TMP=$(mktemp -d 2>/dev/null || echo "/tmp/fh_t5_$$") ; mkdir -p "$T5_TMP"
54
+ (
55
+ cd "$T5_TMP" || exit 1
56
+ git init -q . 2>/dev/null
57
+ mkdir -p scripts
58
+ # a DELIBERATELY hostile helper: it drains stdin, exactly the failure mode under test
59
+ printf '#!/usr/bin/env bash
60
+ cat >/dev/null 2>&1 || true
61
+ exit 0
62
+ ' > scripts/session_close_check.sh
63
+ chmod +x scripts/session_close_check.sh
64
+ cp "$HOOK" ./pre-push-under-test
65
+ # synthetic destructive ref: local sha all-zero => DELETE of refs/heads/victim
66
+ printf 'refs/heads/victim 0000000000000000000000000000000000000000 refs/heads/victim deadbeefdeadbeefdeadbeefdeadbeefdeadbeef
67
+ ' | bash ./pre-push-under-test origin https://example.invalid/x.git >/dev/null 2>&1
68
+ echo "$?" > rc.txt
69
+ )
70
+ T5_RC=$(cat "$T5_TMP/rc.txt" 2>/dev/null || echo "")
71
+ rm -rf "$T5_TMP" 2>/dev/null
72
+ if [ "$T5_RC" = "0" ]; then
73
+ _no "T5 FAIL-OPEN REPRODUCED — a stdin-draining helper let a synthetic branch DELETE through (hook exited 0)"
74
+ elif [ -n "$T5_RC" ]; then
75
+ _ok "T5 stdin-draining helper did NOT disarm the gate (synthetic delete still blocked, exit $T5_RC)"
76
+ else
77
+ _no "T5 could not run end-to-end (no exit code captured) — treat as unverified, not as pass"
78
+ fi
79
+
80
+ # ── PR-only policy guard (2026-07-20 operator decision) ──────────────────────────
81
+ # KNOWN-PAIR calibration per CLAUDE.md §Instrument Calibration: a known-POSITIVE that must block
82
+ # and a known-NEGATIVE that must NOT. A guard that fires on everything is as broken as one that
83
+ # never fires — over-blocking trains MAIN_PUSH_OK=1 into muscle memory, which disarms it.
84
+ _pp_run() { # _pp_run <refline> [env...] -> echoes exit code
85
+ local refline="$1"; shift
86
+ local d; d=$(mktemp -d 2>/dev/null || echo "/tmp/fh_pp_$$"); mkdir -p "$d/scripts"
87
+ ( cd "$d" && git init -q . 2>/dev/null
88
+ printf '#!/usr/bin/env bash\nexit 0\n' > scripts/session_close_check.sh
89
+ chmod +x scripts/session_close_check.sh
90
+ cp "$HOOK" ./h
91
+ printf '%s\n' "$refline" | env "$@" bash ./h origin https://example.invalid/x.git >/dev/null 2>&1
92
+ echo "$?" > rc )
93
+ cat "$d/rc" 2>/dev/null; rm -rf "$d"
94
+ }
95
+ _SHA_A=1111111111111111111111111111111111111111
96
+ _ZERO=0000000000000000000000000000000000000000
97
+ # remote_sha = ZERO (new ref) ISOLATES the PR-only guard from the destructive classifier.
98
+ # First draft used two synthetic non-zero SHAs; the hook could not resolve remote_sha, marked the
99
+ # ref UNCLASSIFIED and fail-closed — so T7/T8 "failed" on the classifier, not on the guard under
100
+ # test. The instrument was measuring itself. (Calibration caught it: the known-NEGATIVE is what
101
+ # exposed it — CLAUDE.md §Instrument Calibration.)
102
+
103
+ # T6 known-POSITIVE: a non-delete push aimed at main MUST block
104
+ rc=$(_pp_run "refs/heads/main $_SHA_A refs/heads/main $_ZERO")
105
+ [ "$rc" = "1" ] && _ok "T6 direct push to main BLOCKED (known-positive)" \
106
+ || _no "T6 direct push to main was NOT blocked (rc=$rc) — PR-only policy is fail-open"
107
+
108
+ # T7 known-NEGATIVE: a feature branch must pass untouched (no over-blocking)
109
+ rc=$(_pp_run "refs/heads/feat/x $_SHA_A refs/heads/feat/x $_ZERO")
110
+ [ "$rc" = "0" ] && _ok "T7 feature-branch push allowed (known-negative, no over-block)" \
111
+ || _no "T7 feature-branch push was blocked (rc=$rc) — guard over-fires; that trains the override"
112
+
113
+ # T8 the override is honored and stays explicit
114
+ rc=$(_pp_run "refs/heads/main $_SHA_A refs/heads/main $_ZERO" MAIN_PUSH_OK=1)
115
+ [ "$rc" = "0" ] && _ok "T8 MAIN_PUSH_OK=1 override honored" \
116
+ || _no "T8 override did not work (rc=$rc) — an unusable escape hatch gets --no-verify instead"
117
+
118
+ echo "── prepush stdin integrity: $([ "$FAILED" -eq 0 ] && echo PASS || echo FAIL) ──"
119
+ exit "$FAILED"