@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.
- 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 +31 -0
- package/docs/ETHOS.md +106 -0
- package/docs/OUTPUT_EVIDENCE.md +118 -0
- package/docs/WHY.md +42 -0
- package/knowledge/patterns/ensemble_union_detection_task_pattern.md +125 -0
- package/knowledge/shared/GLOSSARY.md +77 -0
- package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
- package/knowledge/shared/patterns/multi-persona-review.md +88 -0
- package/knowledge/shared/plugin-catalog/recommended_plugins.md +117 -0
- package/package.json +42 -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/CHANGELOG.md +617 -0
- package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
- package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
- package/scripts/below_floor_scan.sh +91 -0
- package/scripts/chamber_candidate_collect.sh +223 -0
- package/scripts/chamber_run.sh +184 -0
- package/scripts/degrade_direction_scan.sh +222 -0
- package/scripts/fh_env_delta_scan.sh +108 -0
- package/scripts/fh_session_load.sh +202 -0
- package/scripts/gate_pathspec_check.sh +166 -0
- package/scripts/package_coverage_check.sh +119 -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 +28 -0
- package/scripts/session_close_check.sh +171 -0
- package/scripts/substrate_jump_detector.sh +60 -0
- package/scripts/test_degrade_scan_shell_probes.sh +185 -0
- package/scripts/test_marker_floor_lanes.sh +45 -0
- package/scripts/test_prepush_stdin_integrity.sh +119 -0
- package/scripts/tier_census_grep.sh +54 -0
- package/scripts/universal_guard_check.sh +280 -0
- package/templates/.claude/rules/mcp_tool_gating.md +157 -0
- package/templates/.claude/rules/session.md +153 -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/contrib_session.md +34 -0
- package/templates/degrade_direction_scan.sh +222 -0
- package/templates/goal-quench-hook-setup.md +152 -0
- package/templates/predelete_check.sh +72 -0
- package/templates/regression_guard.sh +563 -0
- package/templates/starter_profile.md +83 -0
- 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
|