@chrono-meta/fh-gate 1.4.71 → 1.4.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.claude/rules/.public-surface-patterns.defaults +44 -0
  2. package/.claude/rules/fh_4axis_gate.md +207 -0
  3. package/.claude-plugin/marketplace.json +2 -2
  4. package/AGENTS.md +26 -2
  5. package/CATALOG.md +59 -0
  6. package/README.ja.md +1 -1
  7. package/README.ko.md +1 -1
  8. package/README.md +1 -1
  9. package/README.zh.md +1 -1
  10. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
  11. package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
  12. package/package.json +21 -1
  13. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  14. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  15. package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
  16. package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
  17. package/plugins/fh-meta/skills/salience-splitter/SKILL.md +1 -1
  18. package/scripts/chamber_candidate_collect.sh +223 -0
  19. package/scripts/degrade_direction_scan.sh +222 -0
  20. package/scripts/fh-gate.sh +76 -2
  21. package/scripts/fh_session_load.sh +202 -0
  22. package/scripts/gate_pathspec_check.sh +166 -0
  23. package/scripts/prepush_guard_check.sh +374 -0
  24. package/scripts/psa_scan_lib.sh +153 -0
  25. package/scripts/public_surface_scan_files.sh +157 -0
  26. package/scripts/selfcheck.sh +16 -0
  27. package/scripts/session_close_check.sh +171 -0
  28. package/scripts/test_degrade_scan_shell_probes.sh +185 -0
  29. package/scripts/test_fh_gate_regressions.sh +46 -2
  30. package/scripts/test_prepush_stdin_integrity.sh +119 -0
  31. package/scripts/universal_guard_check.sh +280 -0
  32. package/templates/.claude/rules/mcp_tool_gating.md +157 -0
  33. package/templates/.git-hooks/pre-commit +848 -0
  34. package/templates/.git-hooks/pre-push +585 -0
  35. package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
  36. package/templates/degrade_direction_scan.sh +222 -0
  37. package/templates/predelete_check.sh +72 -0
  38. package/templates/regression_guard.sh +563 -0
@@ -0,0 +1,85 @@
1
+ # Pre-Publish Surface Gate — Portable Checklist
2
+
3
+ > **Run this before making ANY repo or package publicly visible for the first time** — on any machine,
4
+ > in any repo (not only forge-harness). Public exposure is irreversible: a repo is live the instant it
5
+ > flips public and may be cached or forked before you scrub.
6
+ >
7
+ > **Order invariant: scrub before publish, never publish-then-scrub.**
8
+ >
9
+ > Highest risk: a repo **derived from internal/company assets** (operator-IP that originated in a private
10
+ > harness). Origin of this checklist: `phantom-gate` shipped public, then needed a private →
11
+ > de-company-scrub → re-public round-trip. The scrub happened *post*-publish. This checklist inverts that.
12
+
13
+ > **Companion check — structure, not leak (값 스크럽 ≠ 구조 동일):** the inverse risk to the above — when
14
+ > the artifact was *built against a de-id / MOCK proxy* and you now drop it into the **real target**, verify
15
+ > its structural assumptions (column taxonomy, join / exclude rules, schema shape) against **one real-target
16
+ > sample** *before* drop-in. A scrubbed proxy can match on values yet diverge in shape, silently producing
17
+ > wrong output. `found→extend` (read the real target, don't overwrite blind) is the mechanism. This guards
18
+ > *malfunction*, not disclosure — orthogonal to the leak scan below, run both.
19
+
20
+ ## When it applies
21
+
22
+ Any first-time public action:
23
+ - `gh repo create --public` / `gh repo create ... --public`
24
+ - `gh repo edit --visibility public` (private → public flip)
25
+ - first `git push` to a **new public remote**
26
+ - `npm publish` · `python -m build && twine upload dist/*` · any registry publish
27
+
28
+ ## Step 0 — Cheap mechanical pre-flags (10 seconds)
29
+
30
+ Any single hit → **stop, do the full scan below.** These catch the common internal-asset leak class.
31
+
32
+ > ⚠️ **Step 0 is a *pre-filter*, never a clean bill of health.** The `<…>` below are placeholders. If you
33
+ > have **not** filled them from your gitignored source (`.claude/rules/.public-surface-patterns` or
34
+ > `CLAUDE.local.md`), the email line still works but the grep lines match *nothing* and a "no hits"
35
+ > result here means **nothing was scanned**, not "clean". Authority for the verdict is **Step 1 PSA**,
36
+ > which reports `NOT CONFIGURED` (≠ CLEAN) when its pattern source is absent. Never publish on Step 0 alone.
37
+
38
+ ```bash
39
+ # author/commit email = corp domain? (works without config — neutral = personal/noreply)
40
+ git log --format='%ae' | sort -u
41
+
42
+ # LICENSE / README carry a private harness name or internal codename?
43
+ # (substitute your real private values from the gitignored source — literal <…> matches nothing)
44
+ grep -rIE '<private-harness-name>|<internal-codename>|<corp-domain>' LICENSE README* 2>/dev/null
45
+
46
+ # module paths encode internal acronyms? (e.g. detectors/<internal-acronym>/)
47
+ git ls-files | grep -iE '<internal-acronym>'
48
+ ```
49
+
50
+ ## Step 1 — Run both gates (must both be non-LEAK)
51
+
52
+ | Gate | Catches | Pass condition |
53
+ |---|---|---|
54
+ | `/public-surface-audit --target .` | operator-private tokens: real username, corp asset names, absolute home paths, companion-store name | verdict **CLEAN** (or REVIEW with LOW-only, your call) — **not** LEAK, **not** NOT CONFIGURED |
55
+ | `/marketplace-gate` Check 5 | broad public safety: API keys, internal domains, license correctness | Public Safety check passes |
56
+
57
+ - **NOT CONFIGURED ≠ CLEAN.** If PSA reports NOT CONFIGURED, the pattern source is missing — set it up
58
+ (empty file = explicit "nothing to protect"). A missing scan is not a clean bill of health.
59
+ - **Tooling-down = fail-CLOSED, not a free skip.** Publish is an irreversible surface, so if a gate's
60
+ tool is **applicable but unavailable** (skill uninstalled, command errors, backend unreachable), that
61
+ is **not** a pass — do a **manual-equivalent pass** or take an **explicit operator override**, never
62
+ silent-proceed. (A gate is only legitimately skipped when *genuinely not-applicable* — e.g. a
63
+ code-security pass on a repo that ships no code; grep the file list, don't assert "docs-only".)
64
+ - **Generated artifacts count.** An exported HTML/PDF carrying a username is a real public-surface leak —
65
+ fix = regenerate from a sanitized source, not hand-edit.
66
+
67
+ ## Step 2 — If LEAK: scrub on a PRIVATE copy, then publish
68
+
69
+ Do **not** "publish then fix". If the repo is already created, keep it **private** until clean.
70
+
71
+ 1. Keep/flip the repo **private** (`gh repo edit --visibility private` if already created).
72
+ 2. Full de-identify: corp email → neutral, private names/codenames → neutral handle, internal module
73
+ paths → generic, strip internal docs/logs. Re-run Step 1 until **non-LEAK**.
74
+ 3. Only then flip public / push / `twine upload`.
75
+
76
+ ## Step 3 — Record (if this was a near-miss or a real catch)
77
+
78
+ A pre-publish catch is a process win worth logging:
79
+ - forge-harness operator → `tracks/_meta/fh_signal_{date}_{source}.md` (or `field-harvest`).
80
+ - A recurring leak class → candidate `#rule-candidate` (tighten the mechanical pre-flags in Step 0).
81
+
82
+ ---
83
+
84
+ **Done when**: Step 0 pre-flags clean (or escalated to full scan) · Step 1 both gates non-LEAK ·
85
+ publish executed *after* the gates passed, never before.
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env bash
2
+ # degrade_direction_scan.sh — mechanical pre-screen for the "default-toward-PASS" smell
3
+ #
4
+ # The correlated blind spot measured 2026-07-03 across 3 harnesses (qasp/the-bible/pmh):
5
+ # "When a verdict surface cannot mechanically ground its judgment, it defaults toward
6
+ # PASS instead of safe-fail." Same-family review (even frontier + target-tier sim)
7
+ # shares the author's optimistic reading of that discretion and misses it; a
8
+ # different-family auditor catches it. This script is the cheap MECHANICAL pre-screen
9
+ # that runs BEFORE the cross-family pass — it flags the code shapes where a permissive
10
+ # value lands on an unconstrained branch, so the reviewer's attention goes there first.
11
+ #
12
+ # IT IS A REVIEW SURFACE, NOT A HARD GATE. Grep-heuristic → false positives are expected.
13
+ # A hit means "prove this is not default-toward-PASS", not "this is a bug". It never
14
+ # blocks a commit on its own (advisory exit code). The terminal verdict is the
15
+ # cross-family adversarial review + governor source-grounding, never this scan alone.
16
+ # (Irreversibility-gate note: because it is advisory, a degraded/empty run is a no-op,
17
+ # not a free pass — the cross-family review is the load-bearing check it feeds.)
18
+ #
19
+ # NAMED RECALL RESIDUALS (cross-family audit, gpt-5.5, 2026-07-28 — accepted, not closed):
20
+ # * Indirection defeats every probe. `allow() { exit 0; }` … `check || allow` is the same
21
+ # fail-open shape one function call away, and a line-oriented grep cannot follow it. This is
22
+ # inherent to the heuristic, not a bug to patch — it is why the terminal verdict is the
23
+ # cross-family review, and why a clean run is never evidence of safety.
24
+ # * The regression anchor proves the probes on the fixture GRAMMAR it ships, not on every
25
+ # spelling of each class (e.g. `if ! cmd; then :; fi`, arithmetic-context defaults).
26
+ #
27
+ # Usage:
28
+ # bash scripts/degrade_direction_scan.sh [path ...] # scan dirs/files (default: .)
29
+ # git diff --name-only main..HEAD -- '*.py' | xargs bash scripts/degrade_direction_scan.sh
30
+ # Exit: 0 = no smells found; 2 = smells found (ADVISORY signal — do not hard-block on it)
31
+ set -uo pipefail
32
+
33
+ TARGETS=("$@")
34
+ [ ${#TARGETS[@]} -eq 0 ] && TARGETS=(".")
35
+
36
+ # Permissive values a verdict/gate surface must never land on by *default* / fall-through.
37
+ PASS='(True|"PASS"|'"'"'PASS'"'"'|"ALLOW"|'"'"'ALLOW'"'"'|"OK"|'"'"'OK'"'"'|"VALID"|'"'"'VALID'"'"'|"GRANTED"|'"'"'GRANTED'"'"'|"PASSED"|'"'"'PASSED'"'"'|allow|ALLOW)'
38
+
39
+ # Collect target files. Scannable = py + sh (the smell probes are Python-shaped but bash surfaces —
40
+ # incl. this gate's own pre-push/pre-commit-hook trigger category — must not be invisibly dropped).
41
+ # Anything else is tracked as UNSCANNABLE so a load-bearing surface in another language is reported
42
+ # as "not covered", never silently folded into an "advisory clean" (M#2, steel-quench 2026-07-03).
43
+ FILES=(); UNSCANNABLE=()
44
+ for t in "${TARGETS[@]}"; do
45
+ if [ -d "$t" ]; then
46
+ while IFS= read -r f; do FILES+=("$f"); done < <(find "$t" -type f \( -name '*.py' -o -name '*.sh' \) 2>/dev/null)
47
+ # Shebang pass — this is what makes git hooks visible at all. Measured 2026-07-28:
48
+ # `templates/.git-hooks` (files named `pre-push`, no extension, under a dotted directory) —
49
+ # FH's own mechanical floor — reported "no scannable (py/sh) target files", exit 0.
50
+ # Shebang pass. Deliberately NOT restricted to extensionless names: a cross-family audit
51
+ # (2026-07-28, gpt-5.5) found that an earlier draft skipped any dotted basename, so a shell
52
+ # file named `helper.bash` carrying an identical known-positive was dropped from a DIRECTORY
53
+ # target in silence — while the explicit-file branch reported the same file as UNSCANNABLE.
54
+ # Silent-drop on one path and honest-report on the other is the fail-open half. Confirmed by
55
+ # running both paths on the same fixture before accepting the finding.
56
+ while IFS= read -r f; do
57
+ b="${f##*/}" # basename — a dotted DIRECTORY (.git-hooks) is not an extension
58
+ case "$b" in
59
+ *.py|*.sh) continue ;; # already collected above
60
+ *.md|*.json|*.yaml|*.yml|*.txt|*.lock|*.png|*.jpg|*.svg|*.pdf|*.zip) continue ;;
61
+ esac
62
+ head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(ba|z|k)?sh\b' && FILES+=("$f")
63
+ done < <(find "$t" -type f 2>/dev/null)
64
+ elif [ -f "$t" ]; then
65
+ tb="${t##*/}"
66
+ case "$tb" in
67
+ *.py|*.sh) FILES+=("$t") ;;
68
+ *.*) UNSCANNABLE+=("$t") ;;
69
+ *) if head -n1 "$t" 2>/dev/null | grep -qE '^#!.*\b(ba|z|k)?sh\b'; then FILES+=("$t"); else UNSCANNABLE+=("$t"); fi ;;
70
+ esac
71
+ fi
72
+ done
73
+ if [ ${#FILES[@]} -eq 0 ]; then
74
+ if [ ${#UNSCANNABLE[@]} -gt 0 ]; then
75
+ echo "degrade-scan: ${#UNSCANNABLE[@]} changed file(s) are OUTSIDE the scannable set (py/sh) — NOT scanned, NOT 'clean':"
76
+ printf ' (unscannable) %s\n' "${UNSCANNABLE[@]}"
77
+ echo "A load-bearing surface in another language must go straight to cross-family review."
78
+ exit 2 # advisory non-clean — an orchestrator keying on exit code must not read this as clean
79
+ fi
80
+ echo "degrade-scan: no scannable (py/sh) target files"; exit 0
81
+ fi
82
+
83
+ hits=0
84
+ emit() { printf ' %s:%s\n [%s] %s\n' "$1" "$2" "$3" "$4"; hits=$((hits+1)); }
85
+
86
+ for f in "${FILES[@]}"; do
87
+ # ---- Shell-shaped probes (S*) -------------------------------------------------------------
88
+ # Calibration finding (2026-07-28, known-pair): every probe below the S-block is PYTHON-shaped
89
+ # (`except:` / `.get(k, True)` / `if not x:` / `.split()`), none of which exist in bash. A .sh file
90
+ # was still COLLECTED and counted, so a fail-open shell gate printed "no smells in 1 scanned py/sh
91
+ # file" — a FALSE CLEAN, which is worse than honest non-coverage. A known-positive .sh carrying four
92
+ # distinct default-toward-PASS shapes scored 0/4. These probes close that; they run on any file
93
+ # whose basename ends in .sh OR that carries a shell shebang (see the is_sh test below).
94
+ is_sh=""; fb="${f##*/}"
95
+ case "$fb" in
96
+ *.sh) is_sh=1 ;;
97
+ *.py) ;;
98
+ # Any other collected file reached FILES only via the shebang pass, or is a dotted shell name
99
+ # like `helper.bash`. Re-check the shebang rather than keying on the extension — keying on the
100
+ # extension is what produced the collect-but-never-probe false clean this whole block exists to
101
+ # close (n+10). Collected-but-unprobed must not be reachable again.
102
+ *) head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(ba|z|k)?sh\b' && is_sh=1 ;;
103
+ esac
104
+ if [ -n "$is_sh" ]; then
105
+ # S1 — permissive short-circuit on a FAILING CHECK: `scan=$(...) || return 0`, `verify … || exit 0`.
106
+ # The check errored and the surface reports success. Safe-fail is `|| return 1` / `|| exit 1`.
107
+ # SCOPED to check-shaped left-hand sides (command substitution, or a verb like
108
+ # scan/check/verify/grep/audit/validate/gate). A PRECONDITION guard — `[ -d x ] || exit 0`,
109
+ # `[[ $d =~ … ]] || return 0` — is deliberately excluded: "this run does not apply here" is not
110
+ # the same claim as "this check passed". Hand-measured 2026-07-28: unscoped, 6/6 sampled hits
111
+ # were false positives, 4 of them precondition guards.
112
+ # EXCEPTION, re-added after an adversarial pass on this very scoping: a `-f`/`-x` test is a
113
+ # DEPENDENCY check, not a scope check. `[ -f "$GUARD_LIB" ] || exit 0` means "my guard library
114
+ # is missing, therefore allow" — the fail-open shape that bit qasp on 2026-07-28. Excluding it
115
+ # with the scope guards would have hidden exactly the class this scan exists to find.
116
+ while IFS= read -r m; do
117
+ emit "$f" "${m%%:*}" "S1:||→PASS(sh)" "failing check short-circuits to a permissive result (\`|| return 0\` / \`|| exit 0\` / \`|| true\`) — an errored check must fail closed, not report success"
118
+ done < <(grep -nE '\|\|[[:space:]]*(return[[:space:]]+0|exit[[:space:]]+0|true)([[:space:]]*(#|;|$))' "$f" 2>/dev/null \
119
+ | grep -vE '#[[:space:]]*noqa[:[:space:]]*degrade' \
120
+ | grep -vE '^[0-9]+:[[:space:]]*(if[[:space:]]+)?\[\[?[[:space:]]*(-[dznN][[:space:]]|[^]]*=~)' \
121
+ | grep -E '(\$\(|`|\[[[:space:]]*-[fx][[:space:]]|\b(scan|check|verify|validate|audit|grep|gate|assert|lint|test_)[A-Za-z_]*[[:space:](])')
122
+
123
+ # S2 — `else` fall-through to a permissive exit/return within 2 lines (unenumerated case → allow).
124
+ while IFS= read -r ln; do
125
+ emit "$f" "$ln" "S2:else→PASS(sh)" "else/fall-through branch exits permissively — the unenumerated case should fail closed"
126
+ done < <(grep -nE -A2 '^[[:space:]]*else[[:space:]]*$' "$f" 2>/dev/null \
127
+ | grep -E '^[0-9]+[-:][[:space:]]*(exit[[:space:]]+0|return[[:space:]]+0)[[:space:]]*(#.*)?$' \
128
+ | grep -oE '^[0-9]+' | sort -u)
129
+
130
+ # S3 — empty/unset defaulted to a permissive VERDICT: `${V:-PASS}` / `V="PASS"` after a failed read.
131
+ # "the value never arrived" must not be spelled the same way as "the value said PASS".
132
+ # `${V:-0}` and `${V:-true}` are NOT flagged: numeric defaulting is the prescribed integer
133
+ # sanitization against the pipefail-fallback class (see S5), and flagging it would push an
134
+ # author to delete the remedy. Measured 2026-07-28 — `${PRS:-0}` in session_close_check.sh is
135
+ # the fix, not the defect. Only explicit verdict words count.
136
+ while IFS= read -r m; do
137
+ emit "$f" "${m%%:*}" "S3:default→PASS(sh)" "unset/empty defaults to a permissive verdict — absent is not clean (\`not found\` ≠ \`0\`); default to the blocking value"
138
+ done < <(grep -nE "(\\$\{[A-Za-z_][A-Za-z0-9_]*:?-[[:space:]]*(PASS|OK|ALLOW|GRANTED|VALID|PASSED)\}|\|\|[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=[\"']?(PASS|OK|ALLOW|GRANTED|VALID))" "$f" 2>/dev/null \
139
+ | grep -vE '#[[:space:]]*noqa[:[:space:]]*degrade')
140
+
141
+ # S4 — empty-output guard treated as clean: `if [ -z "$out" ]; then return 0/exit 0`.
142
+ # Identical to Probe E's falsy-sentinel class, in shell spelling: an errored scan produces empty
143
+ # output, so "found nothing" and "never ran" become indistinguishable.
144
+ while IFS= read -r ln; do
145
+ emit "$f" "$ln" "S4:empty→PASS(sh)" "empty output treated as clean — a scan that errored also produces empty output; distinguish 'errored/absent' from 'verified clean'"
146
+ done < <(grep -nE -A2 '^[[:space:]]*(if|elif)[[:space:]]+\[+[[:space:]]*-z[[:space:]]' "$f" 2>/dev/null \
147
+ | grep -E '^[0-9]+[-:][[:space:]]*(exit[[:space:]]+0|return[[:space:]]+0)[[:space:]]*(#.*)?$' \
148
+ | grep -oE '^[0-9]+' | sort -u)
149
+
150
+ # S5 — the pipefail-fallback disarm: `... | grep -c ... || echo 0` appends a SECOND line under
151
+ # `set -o pipefail`, so the later `-gt` integer test becomes a bash error (= false) and the guard
152
+ # passes silently, with the error going only to stderr. Measured class, 2026-07-26.
153
+ while IFS= read -r m; do
154
+ emit "$f" "${m%%:*}" "S5:pipefail-fallback(sh)" "\`|| echo 0\` fallback on a pipeline — under \`set -o pipefail\` this yields a multi-line value whose integer comparison errors out and silently passes the guard; split the pipeline and sanitize to an integer"
155
+ done < <(grep -nE '\|[^|]+\|\|[[:space:]]*echo[[:space:]]+[\"'"'"']?0' "$f" 2>/dev/null \
156
+ | grep -vE '#[[:space:]]*noqa[:[:space:]]*degrade')
157
+ fi
158
+
159
+ # Probe A — except/else/finally block returning a permissive value within 2 lines.
160
+ # The classic "swallow the error → report success". A safe-fail returns BLOCK/None/raise.
161
+ while IFS= read -r line; do
162
+ ln="${line%%:*}"
163
+ emit "$f" "$ln" "A:except/else→PASS" "permissive return on an error/fall-through branch — safe-fail must return BLOCK/None or re-raise"
164
+ done < <(grep -nE -A2 '^[[:space:]]*(except([[:space:]][^:]*)?|else|finally)[[:space:]]*:' "$f" 2>/dev/null \
165
+ | grep -E "return[[:space:]]+$PASS([[:space:],)]|$)" | grep -oE '^[0-9]+' | sort -u | sed 's/$/:/')
166
+
167
+ # Probe B — dict default / setdefault to a permissive value (unknown key → PASS).
168
+ while IFS= read -r m; do
169
+ emit "$f" "${m%%:*}" "B:default→PASS" "unknown-key default is permissive — unenumerated case should default to safe-fail"
170
+ done < <(grep -nE "(\.get\([^,]+,[[:space:]]*$PASS[[:space:])]|setdefault\([^,]+,[[:space:]]*$PASS[[:space:])])" "$f" 2>/dev/null)
171
+
172
+ # Probe C — substring membership on a grounding/verdict/state line (loose match, not exact).
173
+ # `if tok in text` masks paid⊂prepaid / 완료⊂미완료. Exact/word-boundary is the safe form.
174
+ while IFS= read -r m; do
175
+ emit "$f" "${m%%:*}" "C:substring-grounding" "substring 'in' on a verdict/state/present line — use exact or word-boundary match, not containment"
176
+ done < <(grep -nE '\b(verdict|present|ground|state|match|expected|assert)\w*\b' "$f" 2>/dev/null \
177
+ | grep -vE ':[[:space:]]*(#|//|from |import )' | grep -vE '#[[:space:]]*noqa[:[:space:]]*degrade' \
178
+ | grep -E '[^._a-zA-Z]in[[:space:]]' | grep -vE '\bfor\b|__contains__|not in|in \(|in \[|in \{|in range|in enumerate|in [A-Z_]+\b' \
179
+ | grep -oE '^[0-9]+' | sed 's/$/:/')
180
+
181
+ # Probe C2 — bare `VAR in VAR` in an if/return/assert/while context, WITHOUT a grounding keyword.
182
+ # Probe C is keyword-gated (low-noise) and therefore misses the doc's own headline example
183
+ # `tok in text` (paid⊂prepaid) when the variables aren't named verdict/state (M#4, steel-quench).
184
+ # C2 closes that: simple var-in-var (not a collection literal / range / for) = a likely
185
+ # containment check that should be exact/word-boundary if it grounds a verdict. Higher noise; advisory.
186
+ while IFS= read -r m; do
187
+ emit "$f" "${m%%:*}" "C2:substring-boolean" "bare 'X in Y' in if/return/assert — if this grounds a presence/verdict check, use exact/word-boundary match, not containment"
188
+ done < <(grep -nE '^[[:space:]]*(if|elif|return|assert|while)[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]+in[[:space:]]+[A-Za-z_][A-Za-z0-9_.]*[[:space:]]*[:)]?[[:space:]]*$' "$f" 2>/dev/null \
189
+ | grep -vE '\bfor\b|in range|in enumerate|not in' \
190
+ | grep -vE '\b(verdict|present|ground|state|match|expected)\w*\b')
191
+
192
+ # Probe E — negated-falsy guard returning permissive (dominance-benchmark round-2 f2 class): an error
193
+ # SENTINEL (None / {} / "" / []) is falsy, so `if not X: return <PASS>` treats "the check errored / never
194
+ # ran" identically to "the check ran and found nothing clean". Distinguish errored from clean before allowing.
195
+ while IFS= read -r ln; do
196
+ emit "$f" "$ln" "E:falsy-sentinel→PASS" "negated-falsy guard returns permissive — a falsy error sentinel (None/{}/'') masquerades as 'clean'; a gate must distinguish 'errored/absent' from 'verified clean'"
197
+ done < <(grep -nE -A2 '^[[:space:]]*if[[:space:]]+not[[:space:]]+[A-Za-z_][A-Za-z0-9_.]*[[:space:]]*:' "$f" 2>/dev/null \
198
+ | grep -E "return[[:space:]]+$PASS([[:space:],)]|$)" | grep -oE '^[0-9]+' | sort -u | sed 's/$/:/')
199
+
200
+ # Probe F — positional field-select from a split result feeding a decision (round-2 c3 class): taking the
201
+ # decision from `parts[-1]`/`parts[0]` of an attacker-influenceable split lets a crafted field (e.g. a
202
+ # signed DENY whose free-form comment ends "::ALLOW") negate the verdict. Validate structure, don't select by position.
203
+ if grep -qE '\.r?split\(' "$f" 2>/dev/null; then
204
+ while IFS= read -r m; do
205
+ emit "$f" "${m%%:*}" "F:split-positional-verdict" "decision taken by position ([-1]/[0]) from a split result — an attacker-controlled trailing/leading field can negate the verdict; validate structure, don't select by position"
206
+ done < <(grep -nE '\[[[:space:]]*-?[01][[:space:]]*\]' "$f" 2>/dev/null \
207
+ | grep -iE 'decision|verdict|allow|deny|approv|grant|status|result|policy')
208
+ fi
209
+ done
210
+
211
+ echo "----"
212
+ [ ${#UNSCANNABLE[@]} -gt 0 ] && printf 'note: %s changed file(s) outside py/sh — NOT covered by this scan (send to cross-family directly).\n' "${#UNSCANNABLE[@]}"
213
+ if [ "$hits" -gt 0 ]; then
214
+ echo "degrade-scan: $hits smell(s) — ADVISORY. Each = 'prove this is not default-toward-PASS'."
215
+ echo "Terminal verdict = cross-family adversarial review (auto-decorrelation), not this scan."
216
+ exit 2
217
+ fi
218
+ # Scope-honest clean message (M#2): "clean" means only "no py/sh-pattern smells in the SCANNED set" —
219
+ # it does NOT assert the changed load-bearing surface is safe (other languages, non-code surfaces,
220
+ # and the lint's own recall gaps are out of scope). The load-bearing check is the cross-family review.
221
+ echo "degrade-scan: no default-toward-PASS smells in ${#FILES[@]} scanned py/sh file(s) — does NOT cover other languages / non-code surfaces / the cross-family check (advisory)."
222
+ exit 0
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bash
2
+ # predelete_check.sh — Destructive-Op Gate step 1: enumerate what branch deletion would lose.
3
+ # Class: measured (mechanical enumeration) — the verdicts feed the gate's judged recovery step.
4
+ # Usage: predelete_check.sh <repo-path> [<base-ref>=origin/main]
5
+ # For every remote branch except the base: commits absent from base + paths absent from base.
6
+ # SAFE — fully merged (0 commits off base)
7
+ # CHECK — commits off base but 0 unique paths: content may still be NEWER than base on shared
8
+ # files (e.g. an unmerged session card) — needs a content-direction look before delete
9
+ # REVIEW — unique paths exist: recover/integrate BEFORE any deletion
10
+ # Exit 1 when any REVIEW exists (blocks a scripted delete chain).
11
+ #
12
+ # DEGRADE DIRECTION — irreversible surface → fail-CLOSED (CLAUDE.md §Irreversibility Surface-Class
13
+ # Degrade Invariant). A FAILED enumeration is NOT an empty (safe) enumeration. Fixed 2026-07-03
14
+ # (cross-family sweep, default-toward-PASS class): the prior version degraded OPEN in three ways —
15
+ # (a) `git branch -r` failing / the repo not resolving → empty for-loop → exit 0 (green-light);
16
+ # (b) a branch or base ref that does not resolve → git errors swallowed by 2>/dev/null → n=0,uniq=0
17
+ # → printed SAFE (an errored ref is not "merged");
18
+ # (c) substring exclusion `grep -vE "origin/(main|HEAD)"` also skipped `origin/main-backup` etc.
19
+ # Each is now closed: a resolution/enumeration failure exits non-zero (exit 2 = harness error, distinct
20
+ # from exit 1 = REVIEW pending); an unresolvable branch ref is classified REVIEW (fail-closed); base/HEAD
21
+ # exclusion is EXACT. A fetch failure is surfaced (stale enumeration is a real risk on this surface),
22
+ # never silently swallowed.
23
+ set -u
24
+ repo="${1:?repo path}"; base="${2:-origin/main}"
25
+ cd "$repo" || { echo "FAIL: cannot cd to repo '$repo'" >&2; exit 2; }
26
+ git rev-parse --git-dir >/dev/null 2>&1 || { echo "FAIL: '$repo' is not a git repo" >&2; exit 2; }
27
+
28
+ # Fetch: surface failure (stale enumeration on an irreversible surface is a real risk), do not swallow.
29
+ if ! git fetch origin --quiet 2>/dev/null; then
30
+ echo "WARN: 'git fetch origin' failed — enumerating against possibly-STALE remote refs" >&2
31
+ fi
32
+
33
+ # Base must resolve, else every comparison is bogus and the old code would misclassify SAFE → fail-closed.
34
+ git rev-parse --verify --quiet "${base}^{commit}" >/dev/null || {
35
+ echo "FAIL: base ref '$base' does not resolve — cannot enumerate; refusing to green-light delete" >&2
36
+ exit 2; }
37
+
38
+ # Branch list: distinguish "command failed" (fail-closed) from "0 remote branches" (legitimately nothing).
39
+ if ! branches_raw=$(git branch -r 2>/dev/null); then
40
+ echo "FAIL: 'git branch -r' failed — cannot enumerate remote branches" >&2; exit 2
41
+ fi
42
+
43
+ fail=0
44
+ while IFS= read -r ref; do
45
+ ref="${ref#"${ref%%[![:space:]]*}"}" # left-trim leading whitespace
46
+ ref="${ref%% *}" # drop any '-> origin/main' HEAD annotation
47
+ [ -z "$ref" ] && continue
48
+ # EXACT base/HEAD exclusion — a substring test would wrongly skip 'origin/main-backup'.
49
+ [ "$ref" = "$base" ] && continue
50
+ [ "$ref" = "origin/main" ] && continue
51
+ [ "$ref" = "origin/HEAD" ] && continue
52
+ b="${ref#origin/}"
53
+ # Ref must resolve, else classify REVIEW (fail-closed) — an errored ref is NOT 'merged/SAFE'.
54
+ if ! git rev-parse --verify --quiet "${ref}^{commit}" >/dev/null; then
55
+ echo "REVIEW $b — ref does not resolve (enumeration error) → do NOT delete blind"
56
+ fail=1; continue
57
+ fi
58
+ n=$(git log --oneline "${base}..${ref}" 2>/dev/null | wc -l | tr -d ' ')
59
+ uniq=$(comm -23 <(git ls-tree -r --name-only "$ref" 2>/dev/null | sort) \
60
+ <(git ls-tree -r --name-only "$base" 2>/dev/null | sort) | wc -l | tr -d ' ')
61
+ if [ "$uniq" -gt 0 ]; then
62
+ echo "REVIEW $b — unique paths: $uniq, commits off base: $n → recover/integrate BEFORE delete"
63
+ fail=1
64
+ elif [ "$n" -gt 0 ]; then
65
+ echo "CHECK $b — $n commits off base, 0 unique paths → verify content superseded (newer-version-on-shared-file risk; tip: compare tip date vs base coverage)"
66
+ else
67
+ echo "SAFE $b — fully merged"
68
+ fi
69
+ done <<< "$branches_raw"
70
+ echo "--"
71
+ echo "Gate order: enumerate -> recover -> destroy. REVIEW blocks; CHECK needs a judged look; only then delete."
72
+ exit $fail