@chrono-meta/fh-gate 1.4.71 → 1.4.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/.public-surface-patterns.defaults +44 -0
- package/.claude/rules/fh_4axis_gate.md +207 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +26 -2
- package/CATALOG.md +59 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
- package/package.json +21 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
- package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
- package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
- package/plugins/fh-meta/skills/salience-splitter/SKILL.md +1 -1
- package/scripts/chamber_candidate_collect.sh +223 -0
- package/scripts/degrade_direction_scan.sh +222 -0
- package/scripts/fh-gate.sh +76 -2
- package/scripts/fh_session_load.sh +202 -0
- package/scripts/gate_pathspec_check.sh +166 -0
- package/scripts/prepush_guard_check.sh +374 -0
- package/scripts/psa_scan_lib.sh +153 -0
- package/scripts/public_surface_scan_files.sh +157 -0
- package/scripts/selfcheck.sh +16 -0
- package/scripts/session_close_check.sh +171 -0
- package/scripts/test_degrade_scan_shell_probes.sh +185 -0
- package/scripts/test_fh_gate_regressions.sh +46 -2
- package/scripts/test_prepush_stdin_integrity.sh +119 -0
- package/scripts/universal_guard_check.sh +280 -0
- package/templates/.claude/rules/mcp_tool_gating.md +157 -0
- package/templates/.git-hooks/pre-commit +848 -0
- package/templates/.git-hooks/pre-push +585 -0
- package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
- package/templates/degrade_direction_scan.sh +222 -0
- package/templates/predelete_check.sh +72 -0
- package/templates/regression_guard.sh +563 -0
|
@@ -0,0 +1,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
|