@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.
- package/.claude/rules/.public-surface-patterns.defaults +44 -0
- package/.claude/rules/fh_4axis_gate.md +207 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +26 -2
- package/CATALOG.md +59 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
- package/package.json +21 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
- package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
- package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
- package/plugins/fh-meta/skills/salience-splitter/SKILL.md +1 -1
- package/scripts/chamber_candidate_collect.sh +223 -0
- package/scripts/degrade_direction_scan.sh +222 -0
- package/scripts/fh-gate.sh +76 -2
- package/scripts/fh_session_load.sh +202 -0
- package/scripts/gate_pathspec_check.sh +166 -0
- package/scripts/prepush_guard_check.sh +374 -0
- package/scripts/psa_scan_lib.sh +153 -0
- package/scripts/public_surface_scan_files.sh +157 -0
- package/scripts/selfcheck.sh +16 -0
- package/scripts/session_close_check.sh +171 -0
- package/scripts/test_degrade_scan_shell_probes.sh +185 -0
- package/scripts/test_fh_gate_regressions.sh +46 -2
- package/scripts/test_prepush_stdin_integrity.sh +119 -0
- package/scripts/universal_guard_check.sh +280 -0
- package/templates/.claude/rules/mcp_tool_gating.md +157 -0
- package/templates/.git-hooks/pre-commit +848 -0
- package/templates/.git-hooks/pre-push +585 -0
- package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
- package/templates/degrade_direction_scan.sh +222 -0
- package/templates/predelete_check.sh +72 -0
- package/templates/regression_guard.sh +563 -0
|
@@ -0,0 +1,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"
|