@chrono-meta/fh-gate 1.4.72 → 1.4.74

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 (51) 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/docs/ETHOS.md +106 -0
  7. package/docs/OUTPUT_EVIDENCE.md +118 -0
  8. package/docs/WHY.md +42 -0
  9. package/knowledge/patterns/ensemble_union_detection_task_pattern.md +125 -0
  10. package/knowledge/shared/GLOSSARY.md +77 -0
  11. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
  12. package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
  13. package/knowledge/shared/patterns/multi-persona-review.md +88 -0
  14. package/knowledge/shared/plugin-catalog/recommended_plugins.md +117 -0
  15. package/package.json +42 -1
  16. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  17. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  18. package/plugins/fh-meta/CHANGELOG.md +617 -0
  19. package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
  20. package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
  21. package/scripts/below_floor_scan.sh +91 -0
  22. package/scripts/chamber_candidate_collect.sh +223 -0
  23. package/scripts/chamber_run.sh +184 -0
  24. package/scripts/degrade_direction_scan.sh +222 -0
  25. package/scripts/fh_env_delta_scan.sh +108 -0
  26. package/scripts/fh_session_load.sh +202 -0
  27. package/scripts/gate_pathspec_check.sh +166 -0
  28. package/scripts/package_coverage_check.sh +119 -0
  29. package/scripts/prepush_guard_check.sh +374 -0
  30. package/scripts/psa_scan_lib.sh +153 -0
  31. package/scripts/public_surface_scan_files.sh +157 -0
  32. package/scripts/selfcheck.sh +28 -0
  33. package/scripts/session_close_check.sh +171 -0
  34. package/scripts/substrate_jump_detector.sh +60 -0
  35. package/scripts/test_degrade_scan_shell_probes.sh +185 -0
  36. package/scripts/test_marker_floor_lanes.sh +45 -0
  37. package/scripts/test_prepush_stdin_integrity.sh +119 -0
  38. package/scripts/tier_census_grep.sh +54 -0
  39. package/scripts/universal_guard_check.sh +280 -0
  40. package/templates/.claude/rules/mcp_tool_gating.md +157 -0
  41. package/templates/.claude/rules/session.md +153 -0
  42. package/templates/.git-hooks/pre-commit +848 -0
  43. package/templates/.git-hooks/pre-push +585 -0
  44. package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
  45. package/templates/contrib_session.md +34 -0
  46. package/templates/degrade_direction_scan.sh +222 -0
  47. package/templates/goal-quench-hook-setup.md +152 -0
  48. package/templates/predelete_check.sh +72 -0
  49. package/templates/regression_guard.sh +563 -0
  50. package/templates/starter_profile.md +83 -0
  51. package/templates/temper_check.sh +46 -0
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # test_marker_floor_lanes.sh — regression fixtures for pre-commit validate_marker_floor lanes.
3
+ #
4
+ # Ships with the 2026-07-10 sonnet-floor lane (Sonnet-Floor Doctrine): each closed hole gets a
5
+ # mechanical regression test (Field-Harness Load-Bearing Change Gate convergence condition).
6
+ # Fixtures assert BOTH directions: the new lane admits exactly its intended shape, and every
7
+ # pre-existing guard still blocks (no degrade-toward-permissive regression).
8
+ #
9
+ # Usage: bash scripts/test_marker_floor_lanes.sh Exit: 0 = all fixtures behave; 1 = regression.
10
+
11
+ set -uo pipefail
12
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
13
+ HOOK="$REPO_ROOT/templates/.git-hooks/pre-commit"
14
+ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
15
+
16
+ sed -n '/^marker_recreate_hint()/,/^}/p;/^validate_marker_floor()/,/^}/p' "$HOOK" > "$T/fn.sh"
17
+
18
+ run() { bash -c "source '$T/fn.sh'; validate_marker_floor '$1'" >/dev/null 2>&1; }
19
+
20
+ FAIL=0
21
+ check() { # $1=fixture $2=expected(PASS|BLOCK) $3=label
22
+ if run "$1"; then got=PASS; else got=BLOCK; fi
23
+ if [ "$got" = "$2" ]; then echo "✅ $3 → $got"; else echo "❌ $3 → $got (expected $2)"; FAIL=1; fi
24
+ }
25
+
26
+ printf 'axis2-engine: inline\naxis2-model: sonnet\nfloor-status: sonnet-floor\naxis2-anchor: regression test 5/5 pass\naxis2-evidence: PASS no-S, 2B applied\n' > "$T/m1"
27
+ printf 'axis2-engine: inline\naxis2-model: sonnet\nfloor-status: sonnet-floor\naxis2-evidence: PASS no-S\n' > "$T/m2"
28
+ printf 'axis2-engine: inline\naxis2-model: haiku\nfloor-status: sonnet-floor\naxis2-anchor: probe 3/3\naxis2-evidence: PASS no-S\n' > "$T/m3"
29
+ printf 'axis2-engine: inline\naxis2-model: sonnet\nfloor-status: at-floor\naxis2-evidence: PASS no-S\n' > "$T/m4"
30
+ printf 'axis2-engine: inline\naxis2-model: haiku\nfloor-status: below-floor\naxis2-evidence: PASS no-S\n' > "$T/m5"
31
+ printf 'axis2-engine: quench-challenger\naxis2-model: opus\nfloor-status: at-floor\naxis2-evidence: 1S/4A fixed\n' > "$T/m6"
32
+ printf 'axis2-engine: inline\naxis2-model: haiku\nfloor-status: below-floor\nbelow-floor-ack: "approved, proceed" — canary-only change\naxis2-evidence: PASS no-S\n' > "$T/m7"
33
+ printf 'axis2-engine: inline\naxis2-model: opus\nfloor-status: bogus-status\naxis2-evidence: PASS\n' > "$T/m8"
34
+
35
+ check "$T/m1" PASS "sonnet-floor + anchor + sonnet model (new lane, intended shape)"
36
+ check "$T/m2" BLOCK "sonnet-floor WITHOUT anchor (anchor is the compensating requirement)"
37
+ check "$T/m3" BLOCK "haiku claiming sonnet-floor (lane mislabel)"
38
+ check "$T/m4" BLOCK "sonnet claiming at-floor (2026-06-10 mislabel class — guard intact)"
39
+ check "$T/m5" BLOCK "below-floor without ack (guard intact)"
40
+ check "$T/m6" PASS "opus at-floor (legacy lane intact)"
41
+ check "$T/m7" PASS "below-floor with quoted ack (legacy lane intact)"
42
+ check "$T/m8" BLOCK "invalid floor-status (fail-closed on unknown value)"
43
+
44
+ [ "$FAIL" -eq 0 ] && echo "── all marker-floor lane fixtures behave ──"
45
+ exit "$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"
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bash
2
+ # tier_census_grep.sh — word-boundary tier-reference census helper (Sonnet-Floor Doctrine).
3
+ #
4
+ # WHY (origin: fh_signal_2026-07-10_session — Sonnet full-loop probe): the naive census pattern
5
+ # `opus|sonnet|haiku|floor|tier|model:` false-positives heavily ("frontier" matches `tier`,
6
+ # "floors" prose, method-sense "model"). The probe self-corrected, but per the doctrine's own
7
+ # prescription ladder (step 1: mechanize) the discipline belongs in a script, not re-derived
8
+ # per session. Built 2026-07-10 on operator instruction (evidence-threshold overridden by
9
+ # explicit "complete it" — recorded, not silent).
10
+ #
11
+ # WHAT: emits candidate tier-reference hits with word-boundary patterns, one line per hit
12
+ # (file:line:text), for the auditor to CLASSIFY per sonnet_floor_doctrine.md's table
13
+ # (trust-floor / availability-gate / advisory / N-A). The script finds candidates; the
14
+ # classification stays a judged step with the doctrine table as its anchor.
15
+ #
16
+ # Sense-filter hints (printed, not auto-applied — de-noising must never hide a real gate):
17
+ # - "frontier|multi-tier|C-tier|A/B-tier" → usually N/A (different axis: content/data tiers)
18
+ # - "hub model|mental model|data model" → usually N/A (methodology sense of "model")
19
+ # - execution tier S/M/L/XL → N/A (token budget, not model tier — fh_detail_protocols)
20
+ #
21
+ # Usage: bash scripts/tier_census_grep.sh <file> [file...] Exit: 0 always (census, not gate)
22
+
23
+ set -uo pipefail
24
+
25
+ if [ $# -eq 0 ]; then
26
+ echo "usage: bash scripts/tier_census_grep.sh <file> [file...]" >&2
27
+ exit 0
28
+ fi
29
+
30
+ PATTERN='\b(opus|sonnet|haiku|fable)\b|\bfloor(-status|-tier|s)?\b|\btiers?\b|(^|[^a-zA-Z])model:'
31
+
32
+ for f in "$@"; do
33
+ if [ ! -f "$f" ]; then
34
+ echo "── $f: NOT FOUND (phantom input — check the path) ──"
35
+ continue
36
+ fi
37
+ echo "── census candidates: $f ──"
38
+ # -P where available (GNU/pcre); BSD grep on macOS supports -E word boundaries via [[:<:]] —
39
+ # portable route: grep -nEi with \b works on GNU; on BSD use perl fallback.
40
+ if echo x | grep -P 'x' >/dev/null 2>&1; then
41
+ grep -nPi "$PATTERN" "$f" || echo " (0 candidates)"
42
+ else
43
+ # 0-hit에도 "(0 candidates)"를 찍는다 — GNU 분기와 출력 대칭 (pmh-parity 포트가 잡은 갭, 역이식 2026-07-10)
44
+ hits=$(perl -ne 'print "$.:$_" if /\b(opus|sonnet|haiku|fable)\b|\bfloor(-status|-tier|s)?\b|\btiers?\b|(^|[^a-zA-Z])model:/i' "$f")
45
+ if [ -n "$hits" ]; then printf '%s\n' "$hits"; else echo " (0 candidates)"; fi
46
+ fi
47
+ done
48
+
49
+ cat <<'HINTS'
50
+ ── classify each hit per sonnet_floor_doctrine.md (trust-floor / availability-gate / advisory / N-A) ──
51
+ N/A sense hints (verify, don't auto-drop): frontier·C-tier·A/B-tier (content-tier axis) ·
52
+ "hub/mental/data model" (methodology sense) · S/M/L/XL execution tier (token budget, not model).
53
+ HINTS
54
+ exit 0
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env bash
2
+ # universal_guard_check.sh — known-pair anchor for the pre-commit UNIVERSAL guards.
3
+ #
4
+ # WHAT IT PINS (2026-07-26, N=5 of the gate-locality class):
5
+ # The confidentiality/privacy guards in templates/.git-hooks/pre-commit are SURFACE-scoped
6
+ # ("content is being committed to a public repo"), NOT 4-axis-scoped ("an FH asset changed").
7
+ # They used to be authored below the `exit 0 # No FH assets staged` line, so their scope
8
+ # silently inherited the 4-axis classifier's asset pathspec: a commit staging only non-asset
9
+ # paths skipped the confidentiality scan entirely. Measured then: 46/241 tracked files (19.1%)
10
+ # unscannable that way, 32 also outside npm files[] (no publish-time backstop either).
11
+ # This anchor fails if that coupling is ever reintroduced.
12
+ #
13
+ # It also pins the credential-SHAPE patterns imported the same day from the cross-audited sister
14
+ # asset PromptPartner/agentsmith (tracks/_audit/session_2026_07_26_agentsmith-sister.md), and the
15
+ # single measured false positive they carry (the AWS documentation key), so a future pattern edit
16
+ # cannot silently re-open either direction.
17
+ #
18
+ # WHY BOTH DIRECTIONS ARE PINNED: a gate is only calibrated if it separates a known-positive from
19
+ # a known-negative. Pinning blocks alone would let an over-broad pattern pass this check while
20
+ # training PUBLIC_SURFACE_OK into muscle memory — an over-blocking gate is a disarmed gate.
21
+ #
22
+ # Runs in a THROWAWAY git repo (mktemp): it never touches this repo's index or worktree.
23
+ # Usage: bash scripts/universal_guard_check.sh → exit 0 all pairs hold, 1 otherwise.
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
+ DEFAULTS="$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults"
29
+
30
+ [ -f "$HOOK" ] || { echo "❌ FAIL — hook not found: $HOOK"; exit 1; }
31
+ [ -f "$DEFAULTS" ] || { echo "❌ FAIL — pattern defaults not found: $DEFAULTS"; exit 1; }
32
+
33
+ SANDBOX=$(mktemp -d) || { echo "❌ FAIL — mktemp"; exit 1; }
34
+ trap 'rm -rf "$SANDBOX"' EXIT
35
+
36
+ # ── Test the STAGED blob, not the worktree copy (cross-family audit finding, 2026-07-26) ──
37
+ # A pre-commit anchor that reads the worktree is bypassable: stage a regressed hook or pattern
38
+ # file, restore the worktree copy, and the anchor validates content the commit will not contain.
39
+ # When a path is staged, extract its staged blob and test THAT. Falls back to the worktree copy
40
+ # when the path is not staged (the ordinary "just run the check" case). Fails CLOSED if a staged
41
+ # blob exists but cannot be read — an unreadable subject is not a passing subject.
42
+ #
43
+ # `-c core.quotePath=false --no-renames` for the same two reasons the hook uses them: git quotes
44
+ # non-ASCII paths (so a name-match silently fails), and with rename detection ON a `git mv` of a
45
+ # protected file reports only the DESTINATION — the anchor then found the old path "not staged",
46
+ # fell back to the intact worktree copy, and passed a commit that deletes the very hook it guards
47
+ # (cross-family audit R2, 2026-07-26). With --no-renames the move shows up as a deletion of the
48
+ # protected path, which is caught below and fails closed.
49
+ stage_or_worktree() { # <repo-relative path> <dest> ; echoes the source used
50
+ local rel="$1" dest="$2" status
51
+ status=$(git -C "$REPO_ROOT" -c core.quotePath=false diff --cached --name-status --no-renames 2>/dev/null \
52
+ | awk -F'\t' -v f="$rel" '$2 == f { print $1; exit }')
53
+ case "$status" in
54
+ D) echo deleted-from-index; return 1 ;; # the protected file is being REMOVED — never a pass
55
+ '') : ;; # not staged → worktree copy is what a commit keeps
56
+ *)
57
+ if git -C "$REPO_ROOT" show ":$rel" > "$dest" 2>/dev/null && [ -s "$dest" ]; then
58
+ echo staged; return 0
59
+ fi
60
+ echo unreadable-staged; return 1 ;;
61
+ esac
62
+ cp "$REPO_ROOT/$rel" "$dest" 2>/dev/null && { echo worktree; return 0; }
63
+ echo missing; return 1
64
+ }
65
+
66
+ HOOK_SRC=$(stage_or_worktree "templates/.git-hooks/pre-commit" "$SANDBOX/.hook-under-test") || {
67
+ echo "❌ FAIL — pre-commit blob unreadable ($HOOK_SRC) — fail-closed."; exit 1; }
68
+ DEF_SRC=$(stage_or_worktree ".claude/rules/.public-surface-patterns.defaults" "$SANDBOX/.defaults-under-test") || {
69
+ echo "❌ FAIL — pattern defaults blob unreadable ($DEF_SRC) — fail-closed."; exit 1; }
70
+ HOOK="$SANDBOX/.hook-under-test"
71
+ DEFAULTS="$SANDBOX/.defaults-under-test"
72
+
73
+ git -C "$SANDBOX" init -q 2>/dev/null
74
+ git -C "$SANDBOX" config user.email "test@example.com"
75
+ git -C "$SANDBOX" config user.name "test"
76
+ mkdir -p "$SANDBOX/.claude/rules" "$SANDBOX/scripts"
77
+ cp "$DEFAULTS" "$SANDBOX/.claude/rules/.public-surface-patterns.defaults"
78
+ # The hook now sources the shared scan library, so the sandbox needs it too. When it was missing,
79
+ # the gate correctly failed closed — and 3 of the "clean" pairs still scored PASS because the oracle
80
+ # below did not recognise "scanner cannot run" as a not-armed state. Both were fixed together.
81
+ cp "$REPO_ROOT/scripts/psa_scan_lib.sh" "$SANDBOX/scripts/psa_scan_lib.sh" 2>/dev/null \
82
+ || { echo "❌ FAIL — scripts/psa_scan_lib.sh missing — fail-closed."; exit 1; }
83
+ # An initial commit so the hook's staged-vs-HEAD steps have a HEAD to diff against. Without it
84
+ # they emit "fatal: ambiguous argument 'HEAD'" — harmless to the verdicts here, but noise in a
85
+ # check whose whole job is to make a real signal legible.
86
+ printf 'sandbox\n' > "$SANDBOX/.seed"
87
+ git -C "$SANDBOX" add .seed >/dev/null 2>&1
88
+ git -C "$SANDBOX" commit -qm seed >/dev/null 2>&1
89
+
90
+ # Synthetic operator literal — this file is public, so the anchor must NOT name the real one.
91
+ OVERRIDE="$SANDBOX/.psa_override"
92
+ printf 'HIGH\tzzsynthoperator\n' > "$OVERRIDE"
93
+
94
+ FAILED=0
95
+
96
+ # run_case <name> <path> <content> <expect: leak|clean>
97
+ run_case() {
98
+ local name="$1" path="$2" content="$3" expect="$4" out hasleak
99
+ mkdir -p "$SANDBOX/$(dirname "$path")" 2>/dev/null
100
+ printf '%s\n' "$content" > "$SANDBOX/$path"
101
+ git -C "$SANDBOX" add -- "$path" >/dev/null 2>&1
102
+ out=$(cd "$SANDBOX" && PSA_PATTERNS="$OVERRIDE" bash "$HOOK" 2>&1); local rc=$?
103
+ # ORACLE (hardened after a cross-family audit, 2026-07-26): "no leak line" alone is NOT a safe
104
+ # proxy for "verified clean" — it also describes a hook that errored, exited early, or never
105
+ # reached the scan. That conflation would score an un-run gate as a passing one, which is the
106
+ # exact failure mode this anchor exists to detect. So a `clean` verdict additionally REQUIRES
107
+ # proof that the confidentiality scan actually ran and reported a pass. Anything else is
108
+ # INCONCLUSIVE, and inconclusive fails.
109
+ if printf '%s' "$out" | grep -qE '❌ (HIGH|MED|LOW) leak'; then
110
+ # A printed finding that still exits 0 is a REPORT, not a gate (R3 audit, 2026-07-26). Dropping
111
+ # a FAILED=1 would keep every leak line intact while the commit sails through, and an oracle
112
+ # that reads only the text would call that a pass. The leak verdict therefore requires the
113
+ # hook to have actually blocked.
114
+ if [ "$rc" -ne 0 ]; then hasleak=leak; else hasleak=leak-printed-but-NOT-blocked; fi
115
+ elif printf '%s' "$out" | grep -qF '[Confidentiality] public-surface scan'; then
116
+ if printf '%s' "$out" | grep -qE '(INACTIVE|INCOMPLETE|unusable pattern|cannot run|scanner cannot)'; then
117
+ hasleak=inconclusive-gate-not-armed
118
+ else
119
+ hasleak=clean
120
+ fi
121
+ else
122
+ hasleak=inconclusive-scan-never-ran
123
+ fi
124
+ git -C "$SANDBOX" rm -q --cached -- "$path" >/dev/null 2>&1
125
+ rm -f "$SANDBOX/$path"
126
+ if [ "$hasleak" = "$expect" ]; then
127
+ echo " ✅ $name (expected $expect)"
128
+ else
129
+ echo " ❌ $name — expected $expect, got $hasleak"
130
+ printf '%s\n' "$out" | sed 's/^/ | /' | head -12
131
+ FAILED=1
132
+ fi
133
+ }
134
+
135
+ echo "[universal-guard] known-pair anchor (throwaway repo: $SANDBOX)"
136
+
137
+ # ── Pair 1: the closed hole. Same private token, asset vs non-asset path. ──
138
+ # README.md is NOT in the 4-axis classifier's pathspec; before the 2026-07-26 fix this case
139
+ # produced zero output and exit 0. If the guards are ever moved back below the early exit,
140
+ # THIS is the case that goes clean and fails the anchor.
141
+ run_case "non-asset path, private token → BLOCK" \
142
+ "README.md" "see zzsynthoperator/home" "leak"
143
+ run_case "asset path, private token → BLOCK" \
144
+ "CATALOG.md" "see zzsynthoperator/home" "leak"
145
+
146
+ # ── Pair 2: no over-blocking. Ordinary content on both path classes stays clean. ──
147
+ run_case "non-asset path, clean content → PASS " \
148
+ "README.md" "ordinary documentation, nothing private" "clean"
149
+ run_case "asset path, clean content → PASS " \
150
+ "CATALOG.md" "ordinary catalog entry, nothing private" "clean"
151
+
152
+ # ── Pair 3: credential shapes (imported 2026-07-26) vs the documentation key that must not fire. ──
153
+ # The BLOCK fixtures are ASSEMBLED AT RUNTIME from split literals, so this file's own bytes never
154
+ # contain a matching shape. Same trick the repo already uses to keep a scanner scannable (see
155
+ # .public-surface-patterns.defaults §self-match, and agentsmith's leak-gate TERMS): a fixture file
156
+ # excluded from the scan would be a hole a real secret could sit in, so it is not excluded — it is
157
+ # written so there is nothing to find. Do not "simplify" these back into single literals.
158
+ AWS_FIXTURE="AKIA""1234567890ABCDEF"
159
+ PAT_FIXTURE="ghp""_abcdefghijklmnopqrstuvwxyz012345"
160
+ run_case "AWS key shape → BLOCK" \
161
+ "README.md" "aws_key = $AWS_FIXTURE" "leak"
162
+ # The documentation key is left as a plain literal on purpose: it MUST be exempted by
163
+ # PSA_PLACEHOLDER, so its presence here is itself part of the test.
164
+ run_case "AWS DOC example key → PASS " \
165
+ "README.md" "example only: AKIAIOSFODNN7EXAMPLE" "clean"
166
+ run_case "GitHub PAT shape → BLOCK" \
167
+ "README.md" "token $PAT_FIXTURE" "leak"
168
+ run_case "documented PAT placeholder → PASS " \
169
+ "README.md" "export GH_TOKEN=ghp_xxxx" "clean"
170
+
171
+ # ── Pair 4: MODERN token formats. Every one of these scanned CLEAN against the first import —
172
+ # the borrowed pattern list predates them. Same runtime-assembly rule as above.
173
+ FGPAT_FIXTURE="github""_pat_11ABCDEFGHIJKLMNOPQRST_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH"
174
+ XAPP_FIXTURE="xapp""-1-A1234567890-B1234567890-abcdefghijklmnop"
175
+ SKPROJ_FIXTURE="sk""-proj-abcdefghijklmnopqrstuvwxyz1234567890"
176
+ run_case "GitHub fine-grained PAT → BLOCK" \
177
+ "README.md" "gh = $FGPAT_FIXTURE" "leak"
178
+ run_case "Slack app-level token → BLOCK" \
179
+ "README.md" "slack = $XAPP_FIXTURE" "leak"
180
+ run_case "OpenAI project key → BLOCK" \
181
+ "README.md" "openai = $SKPROJ_FIXTURE" "leak"
182
+
183
+ # ── Pair 5: the exemption must be the EXACT documented key, not "anything ending in EXAMPLE".
184
+ # A shape-shaped exemption let a validly-shaped key pass merely by ending in EXAMPLE.
185
+ NEAR_MISS="AKIA""000000000EXAMPLE"
186
+ run_case "AWS key merely ENDING 'EXAMPLE' → BLOCK" \
187
+ "README.md" "aws = $NEAR_MISS" "leak"
188
+
189
+ # ── Pair 5-b: a placeholder must not SHIELD a real token later on the same line. pre-commit took
190
+ # only the first match per line, so `<doc key> then <real key>` scanned clean — fixed in the
191
+ # pre-push copy first and not propagated here until an R7 sweep found it. Pinned in both anchors now.
192
+ run_case "placeholder BEFORE real, one line → BLOCK" \
193
+ "README.md" "AKIAIOSFODNN7EXAMPLE then $AWS_FIXTURE" "leak"
194
+
195
+ # ── Pair 6: instrument-fault states must FAIL CLOSED, not print a warning and pass. ──
196
+ # Each of these previously passed at commit time while BLOCKING at publish time — the two copies
197
+ # of this logic had diverged in leniency. run_state_case swaps the pattern source rather than the
198
+ # staged content, so it needs its own runner.
199
+ run_state_case() { # <name> <override-content|__NONE__> <defaults:keep|drop> <expect-exit: block|pass>
200
+ local name="$1" ovc="$2" defmode="$3" expect="$4" out rc got
201
+ printf 'operator literal zzsynthoperator\n' > "$SANDBOX/README.md"
202
+ git -C "$SANDBOX" add -- README.md >/dev/null 2>&1
203
+ local ov="$SANDBOX/.psa_state_override"
204
+ if [ "$ovc" = "__NONE__" ]; then rm -f "$ov"; else printf '%s\n' "$ovc" > "$ov"; fi
205
+ local defbak="$SANDBOX/.defaults.bak"
206
+ if [ "$defmode" = drop ]; then mv "$SANDBOX/.claude/rules/.public-surface-patterns.defaults" "$defbak" 2>/dev/null; fi
207
+ out=$(cd "$SANDBOX" && PSA_PATTERNS="$ov" bash "$HOOK" 2>&1); rc=$?
208
+ if [ "$defmode" = drop ]; then mv "$defbak" "$SANDBOX/.claude/rules/.public-surface-patterns.defaults" 2>/dev/null; fi
209
+ git -C "$SANDBOX" rm -q --cached -- README.md >/dev/null 2>&1; rm -f "$SANDBOX/README.md" "$ov"
210
+ if [ "$rc" -ne 0 ]; then got=block; else got=pass; fi
211
+ if [ "$got" = "$expect" ]; then
212
+ echo " ✅ $name (expected $expect)"
213
+ else
214
+ echo " ❌ $name — expected $expect, got $got (exit $rc)"
215
+ printf '%s\n' "$out" | sed 's/^/ | /' | head -10
216
+ FAILED=1
217
+ fi
218
+ }
219
+ run_state_case "no patterns at all (instrument down) → BLOCK" "__NONE__" drop block
220
+ run_state_case "malformed regex in override → BLOCK" "HIGH zzsynth[" keep block
221
+ run_state_case "empty override + defaults present → PASS " "" keep pass
222
+
223
+ # ── Pair 7: pattern-ROW malformations that are not invalid regex. Each of these used to be skipped
224
+ # in silence, i.e. a detector the author believed in that never existed, and a scan that certified
225
+ # clean. Both forms are trivially produced by hand-editing the gitignored override.
226
+ run_state_case "row with a SPACE instead of a TAB → BLOCK" "HIGH zzsynthoperator" keep block
227
+ run_state_case "row with a CRLF line ending → BLOCK" "HIGH zzsynth[$(printf '\r')" keep block
228
+
229
+ # ── Pair 8: non-ASCII staged filename. git quotes it by default; a quoted name matches no real
230
+ # file, so the scan skipped the file entirely. This repo's operator works in Korean — the class is
231
+ # routine here, not exotic.
232
+ run_case "non-ASCII filename, credential → BLOCK" \
233
+ "유출.md" "aws = $AWS_FIXTURE" "leak"
234
+ run_case "non-ASCII filename, clean → PASS " \
235
+ "유출.md" "평범한 문서, 비밀 없음" "clean"
236
+
237
+ # ── Pair 8-b: a C-QUOTED path (backslash in the name). `core.quotePath=false` handles non-ASCII,
238
+ # but git still C-quotes a backslash, and a quoted spelling matches no real file — so the file was
239
+ # never scanned. R5 closed this with NUL-delimited iteration; a later refactor silently reverted the
240
+ # loop to a line-oriented read and reopened it, which its own cross-family pass caught. Pinned so the
241
+ # next refactor cannot revert it quietly.
242
+ run_case "backslash in filename, credential → BLOCK" \
243
+ 'back\slash.md' "aws = $AWS_FIXTURE" "leak"
244
+
245
+ # ── Pair 9: rename-AWAY of the protected file. `git mv`-ing the hook out of its gated path used to
246
+ # report only the DESTINATION, so the classifier saw no gate edit and the anchor fell back to the
247
+ # intact worktree copy — a commit could delete the gate while the gate reported PASS.
248
+ echo " … rename-away of the protected path:"
249
+ RA=$(mktemp -d)
250
+ mkdir -p "$RA/templates/.git-hooks" "$RA/.claude/rules" "$RA/scripts"
251
+ cp "$HOOK" "$RA/templates/.git-hooks/pre-commit"
252
+ cp "$DEFAULTS" "$RA/.claude/rules/.public-surface-patterns.defaults"
253
+ cp "$0" "$RA/scripts/universal_guard_check.sh" 2>/dev/null || true
254
+ ( cd "$RA" && git init -q && git config user.email t@example.com && git config user.name t \
255
+ && git add -A >/dev/null 2>&1 && git commit -qm seed >/dev/null 2>&1 \
256
+ && git mv templates/.git-hooks/pre-commit pre-commit.disabled >/dev/null 2>&1 \
257
+ && git show HEAD:templates/.git-hooks/pre-commit > templates/.git-hooks/pre-commit 2>/dev/null \
258
+ && chmod +x templates/.git-hooks/pre-commit ) || true
259
+ # The worktree copy is RESTORED after staging the move — that is the actual bypass being pinned
260
+ # (index says "gate deleted", worktree says "gate intact"). Without the restore the nested anchor
261
+ # aborts earlier on "hook not found" and the pair would pass for the wrong reason, leaving the
262
+ # staged-blob rename logic unpinned (R3 audit, 2026-07-26).
263
+ if ( cd "$RA" && bash scripts/universal_guard_check.sh >/dev/null 2>&1 ); then
264
+ echo " ❌ rename-away of the gate → expected BLOCK, anchor PASSED"
265
+ FAILED=1
266
+ else
267
+ echo " ✅ rename-away of the gate (expected BLOCK)"
268
+ fi
269
+ rm -rf "$RA"
270
+
271
+ echo
272
+ if [ "$FAILED" -eq 0 ]; then
273
+ echo "[universal-guard] ✅ all known pairs hold"
274
+ exit 0
275
+ fi
276
+ echo "[universal-guard] ❌ BLOCKED — a known pair broke."
277
+ echo " A BLOCK→PASS flip means the guard stopped covering a surface it declares it covers."
278
+ echo " A PASS→BLOCK flip means a pattern got over-broad; over-blocking disarms the gate by"
279
+ echo " training the PUBLIC_SURFACE_OK override into routine use. Fix the cause, not the pair."
280
+ exit 1