@chrono-meta/fh-gate 3.1.4 → 3.2.0

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 (41) hide show
  1. package/.claude/rules/fh_4axis_gate.md +1 -1
  2. package/.claude-plugin/marketplace.json +3 -3
  3. package/AGENTS.md +6 -0
  4. package/CLAUDE.md +50 -6
  5. package/README.ja.md +3 -3
  6. package/README.ko.md +3 -3
  7. package/README.md +3 -3
  8. package/README.zh.md +3 -3
  9. package/docs/OUTPUT_EVIDENCE.md +1 -1
  10. package/knowledge/shared/harness-core/claude_md_gate_details.md +26 -0
  11. package/knowledge/shared/harness-core/fh_three_layer_canon.md +1 -1
  12. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +46 -0
  13. package/knowledge/shared/harness-core/governance_engineering_definition.md +89 -0
  14. package/knowledge/shared/learnings/subagent_invocations_log.yaml +68 -0
  15. package/knowledge/shared/rules/auto_project_mapping.md +1 -1
  16. package/package.json +11 -1
  17. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  18. package/plugins/fh-commons/skills/preprep/SKILL.md +23 -0
  19. package/plugins/fh-commons/skills/preprep/fixtures/font_revert_probe.py +92 -0
  20. package/plugins/fh-commons/skills/preprep/lane_font.py +462 -0
  21. package/plugins/fh-commons/skills/preprep/preprep.py +16 -1
  22. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +17 -0
  23. package/plugins/fh-commons/skills/preprep/test_lane_font.py +452 -0
  24. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  25. package/plugins/fh-meta/CHANGELOG.md +69 -0
  26. package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
  27. package/scripts/doc_claim_triad_scan.py +303 -0
  28. package/scripts/finding_fleet.sh +180 -0
  29. package/scripts/finding_pipeline.sh +213 -0
  30. package/scripts/finding_verifier.sh +144 -0
  31. package/scripts/finding_verify.py +283 -0
  32. package/scripts/gate_pathspec_check.sh +1 -0
  33. package/scripts/gate_shape_scan.sh +106 -0
  34. package/scripts/selfcheck.sh +50 -0
  35. package/scripts/test_doc_claim_triad_lanes.sh +124 -0
  36. package/scripts/test_finding_pipeline_lanes.sh +459 -0
  37. package/scripts/test_gate_shape_scan_lanes.sh +36 -0
  38. package/scripts/test_heavy_classifier_lanes.sh +13 -3
  39. package/scripts/test_preprep_font_lanes.sh +87 -0
  40. package/templates/.git-hooks/pre-commit +6 -5
  41. package/templates/PRE-PUBLISH-CHECKLIST.md +29 -0
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env python3
2
+ """doc_claim_triad_scan.py — 문서가 「A 가 B 를 쓴다」고 적었을 때, A 가 실제로 B 를 «실행»하는가.
3
+
4
+ 왜 존재하나
5
+ -----------
6
+ `[[feedback_rule_misdescribes_its_own_machine]]` — 문서는 정상 발화하는데 «자기 기계에 대한
7
+ 서술»이 틀린 결함 클래스. 코드가 아니라 «주장»이 틀린 것이라 적대검증·블라인드 sim·되돌림이
8
+ 구조적으로 못 잡는다. 이 레포에서 실제로 두 번 났다(둘 다 CLAUDE.md 상주층).
9
+
10
+ 판별자는 «이름이 있나»가 아니라 «실행하나»다 — 실측 known-pair:
11
+ templates/.git-hooks/pre-push:631 bash "$REPO_ROOT/scripts/session_close_check.sh" → 실행
12
+ templates/.git-hooks/pre-push:514 grep -E '(...|predelete_check)\\.sh' → 언급뿐
13
+
14
+ 🟥 이것은 «리뷰 표면»이지 판정이 아니다. 문서가 두 경로를 나란히 적었다고 해서 «A 가 B 를
15
+ 부른다»는 주장인 것은 아니다(과탐 방향). 그래서 출력은 «확인하라»지 «결함이다»가 아니다.
16
+
17
+ 🟥 구조 술어만 쓴다 — FH 고유 어휘(마커·4축·영혼)를 넣지 않는다. 그래야 남의 레포에도 돈다.
18
+ """
19
+ from __future__ import annotations
20
+ import os, re, sys, json, argparse
21
+ from typing import Dict, List, Tuple
22
+
23
+ DOC_EXT = {".md"}
24
+ EXEC_EXT = {".sh", ".py", ".js", ".mjs", ".ts", ".bash"}
25
+ HOOK_NAMES = {"pre-commit", "pre-push", "commit-msg", "post-commit", "prepare-commit-msg"}
26
+
27
+ PATH_RE = re.compile(r'(?<![\w/.-])((?:[\w.-]+/)+[\w.-]+(?:\.(?:sh|py|js|mjs|ts|bash|yml|yaml|json|md))?)')
28
+ SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__", ".playwright-mcp"}
29
+
30
+ def is_execish(p: str) -> bool:
31
+ b = os.path.basename(p)
32
+ return os.path.splitext(b)[1] in EXEC_EXT or b in HOOK_NAMES
33
+
34
+ def walk(root: str, exts=None) -> List[str]:
35
+ out = []
36
+ for dp, dns, fns in os.walk(root):
37
+ dns[:] = [d for d in dns if d not in SKIP_DIRS]
38
+ for fn in fns:
39
+ if exts is None or os.path.splitext(fn)[1] in exts:
40
+ out.append(os.path.join(dp, fn))
41
+ return out
42
+
43
+ def _inside(root: str, path: str) -> bool:
44
+ """path 가 root 안에 «실제로» 있나 — 심링크까지 풀어서 판정한다.
45
+
46
+ 🟥 이 함수가 없으면 «문서가 부르는 경로»가 스캔 루트를 벗어난다. 이 스캐너는 대상 파일의
47
+ 매칭 줄을 증거로 «출력»하므로, `../private/deploy.sh` 한 줄이면 그 파일의 자격증명이 든 줄이
48
+ 리포트에 실린다. 그리고 이 스캐너는 신뢰할 수 없는 문서를 읽는 것이 일이다.
49
+ (cross-family security review 2026-09-09 — 루트 밖 읽기와 스니펫 반환을 프로브로 실증했다.)
50
+ """
51
+ try:
52
+ r = os.path.realpath(root)
53
+ c = os.path.realpath(path)
54
+ except OSError:
55
+ return False
56
+ return c == r or c.startswith(r + os.sep)
57
+
58
+
59
+ def resolve(root: str, p: str) -> str | None:
60
+ # `..` 를 담은 참조는 «해석하기 전에» 거절한다 — 해석 후 판정은 심링크 한 겹에 뚫린다.
61
+ cand = os.path.join(root, p)
62
+ if os.path.isfile(cand) and not os.path.islink(cand) and _inside(root, cand):
63
+ return cand
64
+ base = os.path.basename(p)
65
+ hits = [f for f in walk(root)
66
+ if os.path.basename(f) == base and not os.path.islink(f) and _inside(root, f)]
67
+ return hits[0] if len(hits) == 1 else None
68
+
69
+ # 실행 증거: 인터프리터 뒤 또는 명령 위치. 주석줄과 grep 패턴 안은 제외.
70
+ def executes(src_path: str, target_base: str, target_path: str = "") -> Tuple[str, str]:
71
+ """→ (verdict, evidence) verdict ∈ EXECUTES | MENTIONS-ONLY | ABSENT"""
72
+ try:
73
+ lines = open(src_path, encoding="utf-8", errors="replace").read().split("\n")
74
+ except OSError:
75
+ return ("ABSENT", "unreadable")
76
+ mention = ""
77
+ esc = re.escape(target_base)
78
+ # 🟥 어간으로도 잡는다 — grep 대안식 안에서는 베이스네임이 이어져 있지 않다.
79
+ # 실측: pre-push:514 는 `predelete_check)\.sh` 라 "predelete_check.sh" 로는 0건이다.
80
+ stem = os.path.splitext(target_base)[0]
81
+ stem_re = re.compile(re.escape(stem) + r'[)\\]*' + re.escape(os.path.splitext(target_base)[1]))
82
+ exec_re = re.compile(r'(?:^|[;&|(]|\$\()\s*(?:(?:bash|sh|source|\.|python3?|node|exec|zsh)\s+)?'
83
+ r'[^\n#]{0,120}?' + esc)
84
+ # 🟥 전체 경로가 줄에 있으면 그걸 우선한다 (cross-family codex [A]).
85
+ # basename 만 보면 다른 디렉터리의 동명 파일 실행을 B 실행으로 오인한다.
86
+ for i, ln in enumerate(lines, 1):
87
+ has_full = bool(target_path) and target_path in ln
88
+ if not has_full and target_base not in ln and not stem_re.search(ln):
89
+ continue
90
+ mention = mention or f"{i}: {ln.strip()[:110]}"
91
+ stripped = ln.lstrip()
92
+ if stripped.startswith("#"):
93
+ continue
94
+ # grep/rg 패턴 안이면 언급이다
95
+ if re.search(r'\b(?:grep|rg|egrep|fgrep|ag)\b[^\n]{0,80}' + esc, ln):
96
+ continue
97
+ # 인터프리터 호출 또는 명령 위치
98
+ # 🟥 출력 구문 안의 인터프리터 호출은 실행이 아니다 (cross-family codex [A]).
99
+ # `echo "bash target.sh"` 는 실행처럼 «보이지만» 아무것도 안 돌린다.
100
+ if re.search(r'\b(?:echo|printf|cat|sed|awk)\b[^\n]{0,40}' + esc, ln):
101
+ continue
102
+ if re.search(r'(?:bash|sh|zsh|source|python3?|node|exec)\s+["\']?[^"\']{0,120}' + esc, ln):
103
+ return ("EXECUTES", f"{i}: {ln.strip()[:110]}")
104
+ if exec_re.search(ln) and not re.search(r'(?:echo|printf|cat)\s', ln):
105
+ return ("EXECUTES", f"{i}: {ln.strip()[:110]}")
106
+ return ("MENTIONS-ONLY", mention) if mention else ("ABSENT", "")
107
+
108
+
109
+ # ── 🟥 나열과 주장을 가른다 ───────────────────────────────────────────────────
110
+ # 손검증 2026-09-07: 초판은 «한 줄에 두 실행아티팩트» 를 주장으로 셌는데, 표본이 전부
111
+ # **File:** a.sh · b.sh · c (나열)
112
+ # Fixtures: `x_lanes.sh` · `y_lanes.sh`. (나열)
113
+ # python3 scan.py --files src/a.py (예시 인자)
114
+ # 였다. 즉 «관계» 가 아니라 «공존» 을 쟀다 — [[feedback_metric_measures_presence_not_relation]].
115
+ #
116
+ # 좁히는 술어는 «구조»로만 짠다(언어 중립 — 남의 레포에도 같은 잣대여야 하므로):
117
+ # 두 경로 «사이»의 텍스트가 구분자/공백/괄호/백틱뿐이면 그건 나열이다.
118
+ # 사이에 실질 낱말이 최소 1개 있어야 관계 주장 후보다.
119
+ _SEP_ONLY = re.compile(r'^[\s`\'"*·,;|/()\[\]{}<>&+~\-—–:.]*$')
120
+
121
+ # 🟥 접속사만 남은 사이 = 여전히 나열이다 (cross-family codex 2026-09-07 [B] · 레인 L3 가 빨개져서 확인).
122
+ # 실측: `A · B` `A, B` `"A" "B"` 는 이미 걸렀는데 **`A and B` · `A plus B` 는 통과했다.**
123
+ # 실물 사례 — CATALOG.md:137 `Anchors: X (pre-commit) and Y (pre-push)`.
124
+ #
125
+ # 🟥 **명명된 대가: 이 목록은 언어 의존이다.** 이 파일의 나머지는 구조 술어라 어느 레포에나
126
+ # 같은 잣대인데, 이 한 줄만 «영어·한국어 접속사»를 안다. 남의 레포에 돌릴 때 그 언어의
127
+ # 접속사가 목록에 없으면 **그쪽 나열이 주장으로 세어진다**(= 그쪽 주장 수가 부풀려진다).
128
+ # 측정에 쓸 때 편향 방향: 영어 접속사는 덮으므로 **영어 레포는 더 엄격하게** 걸러진다 —
129
+ # 즉 octo 쪽 후보가 줄고 FH 쪽 분모가 상대적으로 커 보이는 방향이다(우리에게 불리한 쪽).
130
+ _CONNECTORS = {"and", "plus", "or", "및", "와", "과", "그리고", "vs", "versus", "&"}
131
+
132
+ def asserts_relation(line: str, a: str, b: str) -> bool:
133
+ ia, ib = line.find(a), line.find(b)
134
+ if ia < 0 or ib < 0:
135
+ return False
136
+ lo, hi = (ia + len(a), ib) if ia < ib else (ib + len(b), ia)
137
+ if hi <= lo:
138
+ return False
139
+ between = line[lo:hi]
140
+ if len(between) > 200:
141
+ return False
142
+ if _SEP_ONLY.match(between):
143
+ return False
144
+ # 구분자를 걷어낸 나머지가 접속사뿐이면 그것도 나열이다
145
+ words = [w for w in re.split(r'[^\w가-힣&]+', between) if w]
146
+ if words and all(w.lower() in _CONNECTORS for w in words):
147
+ return False
148
+ return True
149
+
150
+ def scan(root: str) -> Dict:
151
+ root = os.path.abspath(root)
152
+ claims = []
153
+ for doc in walk(root, DOC_EXT):
154
+ try:
155
+ lines = open(doc, encoding="utf-8", errors="replace").read().split("\n")
156
+ except OSError:
157
+ continue
158
+ for i, ln in enumerate(lines, 1):
159
+ paths = [p for p in PATH_RE.findall(ln)]
160
+ paths = [p for p in dict.fromkeys(paths)]
161
+ execs = [p for p in paths if is_execish(p)]
162
+ if len(paths) < 2 or not execs:
163
+ continue
164
+ for a in execs:
165
+ for b in paths:
166
+ if b == a or not is_execish(b):
167
+ continue
168
+ if not asserts_relation(ln, a, b):
169
+ continue
170
+ claims.append((os.path.relpath(doc, root), i, a, b, ln.strip()[:150]))
171
+ # 🟥 방향은 문서에서 못 읽는다 — «A 가 B 에 배선됐다» 와 «A 가 B 를 부른다» 가
172
+ # 같은 두 경로로 반대 방향을 뜻한다. 그래서 **무순서 쌍**으로 접고, 어느 한쪽이라도
173
+ # 실행하면 EXECUTES 다. 이 접기를 안 하면 참인 주장 절반이 MENTIONS-ONLY 로 나온다
174
+ # (실측: CLAUDE.md:1422 session_close_check ↔ pre-push).
175
+ # 🟥 명명된 대가 (cross-family codex 2026-09-07 [S]): 이 접기 때문에 **방향이 실제로 반대인
176
+ # 거짓 주장은 통과한다.** 고치지 않는다 — 방향은 산문에서 안 읽히고, 그걸 읽으려는 시도가
177
+ # 이 계기가 세 번 뚫린 이유다. 이 계기는 오탐을 줄이는 쪽을 택하고, 방향 오류는
178
+ # **사람/에이전트 판단에 남긴다**. 리뷰 표면이지 판정이 아니라는 계약과 일관된다.
179
+ seen = set(); results = []
180
+ for doc, ln, a, b, text in claims:
181
+ key = (doc, ln, *sorted((a, b)))
182
+ if key in seen:
183
+ continue
184
+ seen.add(key)
185
+ ap = resolve(root, a); bp = resolve(root, b)
186
+ if ap is None or bp is None:
187
+ miss = "A-MISSING" if ap is None else "B-MISSING"
188
+ results.append(dict(doc=doc, line=ln, a=a, b=b, verdict=miss, ev="", text=text)); continue
189
+ v1, e1 = executes(ap, os.path.basename(b), b)
190
+ v2, e2 = executes(bp, os.path.basename(a), a)
191
+ if v1 == "EXECUTES":
192
+ results.append(dict(doc=doc, line=ln, a=a, b=b, verdict="EXECUTES", ev=f"{a}: {e1}", text=text))
193
+ elif v2 == "EXECUTES":
194
+ results.append(dict(doc=doc, line=ln, a=b, b=a, verdict="EXECUTES", ev=f"{b}: {e2}", text=text))
195
+ else:
196
+ ev = f"{a}: {e1}" if e1 else (f"{b}: {e2}" if e2 else "")
197
+ v = "MENTIONS-ONLY" if (e1 or e2) else "ABSENT"
198
+ results.append(dict(doc=doc, line=ln, a=a, b=b, verdict=v, ev=ev, text=text))
199
+ counts = {}
200
+ for r in results:
201
+ counts[r["verdict"]] = counts.get(r["verdict"], 0) + 1
202
+ return dict(root=root, docs=len(walk(root, DOC_EXT)), claims=len(results),
203
+ counts=counts, results=results)
204
+
205
+ # ── 계기 보정 ────────────────────────────────────────────────────────────────
206
+ def selftest(root: str) -> int:
207
+ """known-pair — 이 레포의 «실물»로 보정한다. 픽스처가 아니라 실재하는 두 관계."""
208
+ pairs = [
209
+ # (A, B, 기대, 왜)
210
+ ("templates/.git-hooks/pre-push", "scripts/session_close_check.sh", "EXECUTES",
211
+ "known-negative: 진짜로 실행한다 (:631 bash ...)"),
212
+ ("templates/.git-hooks/pre-push", "scripts/predelete_check.sh", "MENTIONS-ONLY",
213
+ "known-positive: grep 패턴 안 + 주석뿐 (:514 :705)"),
214
+ ]
215
+ # ── 절반 ①: 주장 추출기 (나열 vs 관계) ──────────────────────────────
216
+ # 🟥 이 보정이 없어서 초판이 «공존» 을 «주장» 으로 세고 756건이라는 거짓 숫자를 냈다.
217
+ # 픽스처는 그때 손검증에서 나온 «실제로 뚫린 표기» 그대로 쓴다.
218
+ A, B = "scripts/foo.sh", "scripts/bar.sh"
219
+ extract_pairs = [
220
+ (f"**File:** {A} \u00b7 {B} \u00b7 templates/.git-hooks/pre-commit", False, "나열(가운뎃점)"),
221
+ (f"Fixtures: `{A}` \u00b7 `{B}`.", False, "나열(백틱+가운뎃점)"),
222
+ (f"Anchors: `{A}`, `{B}`", False, "나열(쉼표)"),
223
+ (f"{A} 는 {B} 를 호출한다", True, "관계(한국어)"),
224
+ (f"`{A}` is wired into `{B}`", True, "관계(영어)"),
225
+ (f"the hook {A} blocks by running {B}", True, "관계(영어·동사)"),
226
+ ]
227
+ print("계기 보정 ① — 주장 추출기 (나열을 주장으로 세지 않는가)")
228
+ bad0 = 0
229
+ for line, want, why in extract_pairs:
230
+ got = asserts_relation(line, A, B)
231
+ ok = got == want
232
+ bad0 += 0 if ok else 1
233
+ print(f" {'PASS' if ok else 'FAIL'} got={str(got):<5} want={str(want):<5} {why}")
234
+ if bad0:
235
+ print(f"\nINSTRUMENT ERROR — 주장 추출기가 {bad0}건을 안 가른다.")
236
+ return 2
237
+ print()
238
+
239
+ print("계기 보정 \u2461 — 실행 판별기 (이 레포의 실물 관계)")
240
+ bad = 0
241
+ for a, b, want, why in pairs:
242
+ ap = resolve(root, a)
243
+ if ap is None:
244
+ print(f" FAIL {a} 를 못 찾음 — 보정 불가"); bad += 1; continue
245
+ # 🟥 B 의 실재도 본다 (cross-family codex, 2026-09-07 [S]).
246
+ # 안 보면 대상이 삭제·이동돼도 «A 가 이름을 언급»한다는 이유로 보정이 통과한다.
247
+ # known-pair 가 조용히 낡는 자리다.
248
+ if resolve(root, b) is None:
249
+ print(f" FAIL {b} 가 실재하지 않는다 — known-pair 가 낡았다"); bad += 1; continue
250
+ got, ev = executes(ap, os.path.basename(b))
251
+ ok = got == want
252
+ bad += 0 if ok else 1
253
+ print(f" {'PASS' if ok else 'FAIL'} {os.path.basename(b):<28} got={got:<14} want={want:<14} {why}")
254
+ if not ok:
255
+ print(f" 증거: {ev[:100]}")
256
+ if bad:
257
+ print(f"\nINSTRUMENT ERROR — {bad} 건이 안 갈린다. 이 스캐너의 숫자를 쓰지 마라.")
258
+ return 2
259
+ print("\n계기가 둘을 가른다. 「실행」과 「언급」이 구별된다.")
260
+ return 0
261
+
262
+ def main() -> int:
263
+ ap = argparse.ArgumentParser()
264
+ ap.add_argument("root", nargs="?", default=".")
265
+ ap.add_argument("--selftest", action="store_true")
266
+ ap.add_argument("--json", action="store_true")
267
+ ap.add_argument("--home", default=None,
268
+ help="외부 레포를 스캔할 때 «실행 판별기» 보정을 어느 레포에서 할지. "
269
+ "executes() 는 파일 내용의 순수 함수라 보정이 이식된다 — "
270
+ "그러나 추출기 보정(합성 픽스처)은 어디서나 같다.")
271
+ ap.add_argument("--show", default="MENTIONS-ONLY,B-MISSING,A-MISSING")
272
+ a = ap.parse_args()
273
+ root = os.path.abspath(a.root)
274
+ cal_root = os.path.abspath(a.home) if a.home else root
275
+ if a.selftest:
276
+ return selftest(cal_root)
277
+ # 보정 없이 숫자를 못 낸다 — 보정 실패면 스캔 안 한다
278
+ if selftest(cal_root) != 0:
279
+ print("\n🟥 보정 실패 → 스캔하지 않는다.")
280
+ return 2
281
+ print()
282
+ r = scan(root)
283
+ if r["claims"] == 0:
284
+ print(f"NO-CLAIMS — 문서 {r['docs']}건에서 «두 실행아티팩트가 한 줄에» 나오는 주장이 0건이다.")
285
+ print("이것은 «결함 0» 이 아니라 «이 계기가 잴 표면이 없음» 이다.")
286
+ return 3
287
+ print(f"문서 {r['docs']}건 · 주장후보 {r['claims']}건")
288
+ for k in sorted(r["counts"], key=lambda x: -r["counts"][x]):
289
+ print(f" {r['counts'][k]:>5} {k}")
290
+ show = set(a.show.split(","))
291
+ flagged = [x for x in r["results"] if x["verdict"] in show]
292
+ print(f"\n── 확인 대상 {len(flagged)}건 (리뷰 표면이지 판정이 아니다) ──")
293
+ for x in flagged[:40]:
294
+ print(f" {x['doc']}:{x['line']} [{x['verdict']}] {x['a']} → {x['b']}")
295
+ if x["ev"]: print(f" A쪽 {x['ev']}")
296
+ if len(flagged) > 40:
297
+ print(f" … 외 {len(flagged)-40}건")
298
+ if a.json:
299
+ print(json.dumps(r, ensure_ascii=False, indent=1))
300
+ return 0
301
+
302
+ if __name__ == "__main__":
303
+ sys.exit(main())
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env bash
2
+ # finding_fleet.sh — run a parallel, multi-family review fleet over one file and emit TYPED findings.
3
+ #
4
+ # WHY. FH's review output has always been prose, so nothing downstream could count it, filter it, or
5
+ # hand it to a second opinion. Measured 2026-09-08 over eight GHSA cases x3: FH made 52 claims with 5
6
+ # wrong about the code (9.6%); a sibling harness made 73 -- 40% more -- with 2 wrong (2.7%). The gap
7
+ # was not reading quality. That harness emits findings as typed records, has a different family stamp
8
+ # each one, and deletes the false positives with a filter. This script is the first half of that shape
9
+ # for FH: fan out to several families in parallel, each returning JSONL. The second half, the reject
10
+ # stage, is scripts/finding_verify.py, and it refuses to let a family verify its own findings.
11
+ #
12
+ # WHAT IS MECHANIZED: that each finding carries the family and role that produced it, that the members
13
+ # run in parallel and independently, and that a member which fails is recorded as failed rather than
14
+ # quietly contributing nothing. WHAT IS NOT: what counts as a defect. No rule about findings lives here.
15
+ #
16
+ # FLEET TABLE. One member per line, `family|role|command`. The command receives the review prompt BOTH
17
+ # on stdin and as a file whose path replaces the token PROMPT_FILE in the command — some CLIs take the
18
+ # prompt as an argument and their -p flag is variadic, so piping it silently turns the next flag into
19
+ # the prompt (measured 2026-09-08: `agy -p --model X` sent "--model" as the prompt and reported it).
20
+ # Must print JSONL findings on stdout. Override with --fleet <file>; the default is two external
21
+ # families so that neither is the Claude governor calling this script.
22
+ #
23
+ # Usage: bash scripts/finding_fleet.sh <target-file> --out <dir> [--fleet <table>] [--roles-only]
24
+ # bash scripts/finding_fleet.sh --selftest
25
+ # Exit: 0 = at least one member returned findings · 1 = all members failed (nothing was reviewed)
26
+ # 2 = usage error
27
+ set -uo pipefail
28
+ export LC_ALL=C
29
+ HERE="$(cd "$(dirname "$0")" && pwd)"
30
+ # Binaries are resolved from PATH, then from the usual install roots. Never hard-code a home path:
31
+ # this file ships in the npm package and a literal home directory is both a leak and wrong on the
32
+ # consumer's machine. Override with FH_CODEX_BIN / FH_AGY_BIN.
33
+ CODEX="${FH_CODEX_BIN:-$(command -v codex 2>/dev/null || echo "$HOME/.npm-global/bin/codex")}"
34
+ AGY="${FH_AGY_BIN:-$(command -v agy 2>/dev/null || echo "$HOME/.local/bin/agy")}"
35
+
36
+ default_fleet() {
37
+ cat <<'EOF'
38
+ codex|logic|CODEX_BIN exec --sandbox read-only --skip-git-repo-check -m gpt-6-astra -c model_reasoning_effort="high"
39
+ gemini|security|AGY_BIN --model gemini-3.8-flash-high --output-format text --print-timeout 5m -p "$(cat PROMPT_FILE)"
40
+ EOF
41
+ }
42
+
43
+ PROMPT_HEAD='You are one reviewer in a parallel fleet. Review the file below for defects in your assigned area.
44
+
45
+ Output ONLY JSON Lines, one object per finding, nothing else — no prose, no code fences:
46
+ {"title":"<short claim>","file":"<name>","line":<int>,"severity":"S|A|B","category":"<one word>","detail":"<what goes wrong and when>","defeater":"<what would be OBSERVED if this claim were wrong>","confidence":<0.0-1.0>}
47
+
48
+ S = exploitable or fail-open. A = real but non-blocking. B = minor.
49
+ `defeater` is required and must name something observable — a value, an output, a code path that
50
+ would be reached — not "if I misread it". A claim whose own falsification condition cannot be stated
51
+ is a claim you are not yet entitled to make.
52
+ Report only defects you can point to a specific line for. If you find none, output nothing.
53
+
54
+ Your assigned area: '
55
+
56
+ run_member() { # $1=family $2=role $3=command $4=target $5=outdir
57
+ local fam="$1" role="$2" cmd="$3" tgt="$4" out="$5"
58
+ # 🟥 치환값은 eval 되는 문자열 «안»으로 들어간다. 결박하지 않으면 «출력 디렉터리 이름»만으로도
59
+ # 명령이 실행된다 — cross-family security review 2026-09-09 가 무해한 printf 로 실증했다.
60
+ # 여기는 bash `eval` 이므로 `printf %q` 가 옳은 도구다(shell=True 로 /bin/sh 에 넘기는
61
+ # finding_pipeline.sh 와는 사정이 다르다 — 거기서는 %q 가 dash 에서 깨져서 argv 로 갔다).
62
+ local q_codex q_agy q_pf
63
+ q_codex="$(printf '%q' "$CODEX")"; q_agy="$(printf '%q' "$AGY")"
64
+ cmd="${cmd//CODEX_BIN/$q_codex}"; cmd="${cmd//AGY_BIN/$q_agy}"
65
+ local raw="$out/raw_${fam}_${role}.txt" pf="$out/prompt_${fam}_${role}.txt"
66
+ # 🟥 심링크를 통해 쓰면 남의 파일을 덮는다. 그리고 타깃이 심링크면 그 «내용»이 외부로 나간다.
67
+ for _p in "$raw" "$pf" "$out/err_${fam}_${role}.txt" "$out/part_${fam}_${role}.jsonl"; do
68
+ [ -L "$_p" ] && { echo "finding_fleet: refusing to write through a symlink: $_p" >&2; return 1; }
69
+ done
70
+ { printf '%s%s\n\n===== FILE: %s =====\n' "$PROMPT_HEAD" "$role" "$(basename "$tgt")"; cat "$tgt"; } > "$pf"
71
+ q_pf="$(printf '%q' "$pf")"
72
+ cmd="${cmd//PROMPT_FILE/$q_pf}"
73
+ eval "$cmd" < "$pf" > "$raw" 2>"$out/err_${fam}_${role}.txt"
74
+ local rc=$?
75
+ FAM="$fam" ROLE="$role" RC="$rc" /usr/bin/python3 - "$raw" "$out/part_${fam}_${role}.jsonl" <<'PY'
76
+ import json, os, sys
77
+ fam, role, rc = os.environ["FAM"], os.environ["ROLE"], os.environ["RC"]
78
+ src, dst = sys.argv[1], sys.argv[2]
79
+ n = 0
80
+ with open(dst, "w", encoding="utf-8") as w:
81
+ for line in open(src, encoding="utf-8", errors="replace"):
82
+ line = line.strip().lstrip("")
83
+ if not line.startswith("{"):
84
+ continue # tolerate banners and fences around the JSONL
85
+ try:
86
+ d = json.loads(line)
87
+ except json.JSONDecodeError:
88
+ continue
89
+ if not d.get("title"):
90
+ continue
91
+ n += 1
92
+ d["id"] = f"{fam}-{role}-{n}"
93
+ d["producer_family"] = fam
94
+ d["producer_role"] = role
95
+ w.write(json.dumps(d, ensure_ascii=False) + "\n")
96
+ print(f"MEMBER family={fam} role={role} rc={rc} findings={n}")
97
+ PY
98
+ }
99
+
100
+ selftest() {
101
+ local d fails; d="$(mktemp -d 2>/dev/null)" || d=""
102
+ [ -n "$d" ] && [ -w "$d" ] || { echo "SELFTEST: ENV-BLOCKED (mktemp -d failed) — unmeasured, not a pass"; return 3; }
103
+ printf 'def f(x):\n return x\n' > "$d/t.py"
104
+ local fails=0
105
+ # Stub members are real scripts: they must consume stdin (a member that ignores it dies of SIGPIPE
106
+ # on a large file, which is a property of the harness, not of the member) and then print.
107
+ cat > "$d/m_ok.sh" <<'EOS'
108
+ #!/bin/sh
109
+ cat >/dev/null
110
+ echo '{"title":"t","file":"t.py","line":1,"severity":"B","confidence":0.5}'
111
+ EOS
112
+ cat > "$d/m_prose.sh" <<'EOS'
113
+ #!/bin/sh
114
+ cat >/dev/null
115
+ echo "I reviewed it and it looks fine to me."
116
+ EOS
117
+ cat > "$d/m_fail.sh" <<'EOS'
118
+ #!/bin/sh
119
+ cat >/dev/null
120
+ exit 1
121
+ EOS
122
+ chmod +x "$d/m_ok.sh" "$d/m_prose.sh" "$d/m_fail.sh"
123
+ printf 'stub|logic|sh %s\n' "$d/m_ok.sh" > "$d/fleet"
124
+ bash "$0" "$d/t.py" --out "$d/o1" --fleet "$d/fleet" >/dev/null 2>&1
125
+ if [ -s "$d/o1/findings.jsonl" ] && /usr/bin/grep -q '"producer_family": "stub"' "$d/o1/findings.jsonl"; then
126
+ echo " ✅ known-positive: a member's JSONL survives and is tagged with its family"
127
+ else echo " ❌ known-positive parse/tag"; fails=1; fi
128
+ printf 'proser|logic|sh %s\n' "$d/m_prose.sh" > "$d/fleet2"
129
+ bash "$0" "$d/t.py" --out "$d/o2" --fleet "$d/fleet2" >/dev/null 2>&1
130
+ if [ ! -s "$d/o2/findings.jsonl" ]; then echo " ✅ known-negative: prose contributes no findings"
131
+ else echo " ❌ known-negative: prose leaked into findings"; fails=1; fi
132
+ printf 'faily|logic|sh %s\n' "$d/m_fail.sh" > "$d/fleet3"
133
+ bash "$0" "$d/t.py" --out "$d/o3" --fleet "$d/fleet3" >/dev/null 2>&1
134
+ if /usr/bin/grep -q 'rc=1' "$d/o3/members.txt" 2>/dev/null; then echo " ✅ failing member recorded with its exit code"
135
+ else echo " ❌ failing member not recorded"; fails=1; fi
136
+ if [ ! -s "$d/o3/findings.jsonl" ] && ! bash "$0" "$d/t.py" --out "$d/o4" --fleet "$d/fleet3" >/dev/null 2>&1; then
137
+ echo " ✅ all-members-failed exits non-zero (empty list must not read as clean)"
138
+ else echo " ❌ all-members-failed did not exit non-zero"; fails=1; fi
139
+ /bin/rm -rf "$d"
140
+ [ "$fails" -eq 0 ] && { echo "SELFTEST: PASS"; return 0; } || { echo "SELFTEST: FAIL"; return 1; }
141
+ }
142
+
143
+ [ $# -ge 1 ] || { echo "usage: $0 <target-file> --out <dir> [--fleet <table>] | --selftest" >&2; exit 2; }
144
+ [ "$1" = "--selftest" ] && { selftest; exit $?; }
145
+ TARGET="$1"; shift
146
+ OUT=""; FLEET=""
147
+ while [ $# -gt 0 ]; do
148
+ case "$1" in
149
+ --out) OUT="${2:-}"; shift 2 ;;
150
+ --fleet) FLEET="${2:-}"; shift 2 ;;
151
+ *) echo "unknown flag: $1" >&2; exit 2 ;;
152
+ esac
153
+ done
154
+ [ -f "$TARGET" ] || { echo "no such file: $TARGET" >&2; exit 2; }
155
+ # 🟥 이 파일의 «내용»은 외부 모델로 전송된다. 심링크면 체크아웃 밖 자격증명을 가리킬 수 있고,
156
+ # 그러면 리뷰 대상 대신 그 비밀이 프롬프트가 된다 (residency 위반, cross-family 2026-09-09).
157
+ [ -L "$TARGET" ] && { echo "finding_fleet: target is a symlink — refusing (content is SENT to an external model)" >&2; exit 2; }
158
+ [ -r "$TARGET" ] || { echo "finding_fleet: target is not readable: $TARGET" >&2; exit 2; }
159
+ # 프롬프트 파일은 소스 전문을 담는다 — umask 022 면 0644 로 남아 다른 계정이 읽는다.
160
+ umask 077
161
+ [ -n "$OUT" ] || { echo "--out is required" >&2; exit 2; }
162
+ mkdir -p "$OUT"
163
+ if [ -n "$FLEET" ]; then cp "$FLEET" "$OUT/fleet.txt"; else default_fleet > "$OUT/fleet.txt"; fi
164
+
165
+ : > "$OUT/members.txt"
166
+ while IFS='|' read -r fam role cmd; do
167
+ [ -n "${fam:-}" ] || continue
168
+ case "$fam" in \#*) continue ;; esac
169
+ run_member "$fam" "$role" "$cmd" "$TARGET" "$OUT" >> "$OUT/members.txt" 2>&1 &
170
+ done < "$OUT/fleet.txt"
171
+ wait
172
+
173
+ cat "$OUT"/part_*.jsonl > "$OUT/findings.jsonl" 2>/dev/null || : > "$OUT/findings.jsonl"
174
+ TOTAL=$(/usr/bin/wc -l < "$OUT/findings.jsonl" | /usr/bin/tr -d ' ')
175
+ MEMBERS=$(/usr/bin/wc -l < "$OUT/fleet.txt" | /usr/bin/tr -d ' ')
176
+ OK=$(/usr/bin/grep -c 'rc=0' "$OUT/members.txt" 2>/dev/null); OK=${OK:-0} # 🟥 never `|| echo 0` here: grep prints 0 AND the fallback echoes 0, giving "0\n0"
177
+ echo "FLEET members=$MEMBERS ok=$OK findings=$TOTAL out=$OUT"
178
+ cat "$OUT/members.txt"
179
+ [ "$OK" -gt 0 ] || { echo "🟥 every member failed — nothing was reviewed, and an empty finding list here means UNREVIEWED, not clean"; exit 1; }
180
+ exit 0