@chrono-meta/fh-gate 3.0.0 → 3.1.1

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 (111) hide show
  1. package/.claude/regression/probes_live.yaml +137 -0
  2. package/.claude/rules/.residency-patterns.defaults +7 -0
  3. package/.claude/rules/fh_4axis_gate.md +50 -1
  4. package/.claude-plugin/marketplace.json +8 -2
  5. package/AGENTS.md +27 -0
  6. package/CATALOG.md +17 -0
  7. package/CLAUDE.md +12 -2
  8. package/README.ja.md +51 -7
  9. package/README.ko.md +48 -7
  10. package/README.md +37 -5
  11. package/README.zh.md +45 -8
  12. package/docs/STANDARDS_ALIGNMENT.md +61 -0
  13. package/docs/USER_GUIDE.md +3 -0
  14. package/docs/USE_CASES.md +50 -0
  15. package/docs/model_tier_expectations.md +60 -0
  16. package/knowledge/shared/harness-core/fh_three_layer_canon.md +30 -10
  17. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +8 -0
  18. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +9 -0
  19. package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +139 -0
  20. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +20 -0
  21. package/knowledge/shared/learnings/subagent_invocations_log.yaml +451 -4
  22. package/package.json +40 -2
  23. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  24. package/plugins/fh-commons/skills/preprep/README.md +4 -1
  25. package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
  26. package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
  27. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  28. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  29. package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
  30. package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
  31. package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
  32. package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
  33. package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
  34. package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
  35. package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
  36. package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
  37. package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
  38. package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
  39. package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
  40. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
  41. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
  42. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  43. package/plugins/fh-meta/CHANGELOG.md +76 -1
  44. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
  45. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
  46. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
  47. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
  48. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
  49. package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
  50. package/plugins/fh-qp/README.md +71 -0
  51. package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
  52. package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
  53. package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
  54. package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
  55. package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
  56. package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
  57. package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
  58. package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
  59. package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
  60. package/plugins/fh-qp/qp_profile.example.yaml +29 -0
  61. package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
  62. package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
  63. package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
  64. package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
  65. package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
  66. package/scripts/chamber_run.sh +14 -5
  67. package/scripts/com.forge-harness.live-eval.plist +84 -0
  68. package/scripts/compaction_probe.sh +9 -35
  69. package/scripts/directional_diff_gate.sh +14 -2
  70. package/scripts/frontier_digest_autopilot.sh +4 -1
  71. package/scripts/map_postprocess.py +90 -0
  72. package/scripts/outbound_query_guard.sh +131 -0
  73. package/scripts/outbound_query_hook.sh +373 -0
  74. package/scripts/package_coverage_check.sh +55 -16
  75. package/scripts/pipe_verdict_guard.sh +41 -1
  76. package/scripts/probe_live_eval.sh +240 -0
  77. package/scripts/probe_live_eval_lib.py +579 -0
  78. package/scripts/proposal_hook.sh +120 -17
  79. package/scripts/push_zone_check.sh +78 -0
  80. package/scripts/residency_closure_scan.py +252 -0
  81. package/scripts/selfcheck.sh +41 -1
  82. package/scripts/session_close_check.sh +100 -0
  83. package/scripts/sim_isolated_run.sh +98 -2
  84. package/scripts/test_action_yml_lanes.sh +160 -0
  85. package/scripts/test_fh_qp_lanes.sh +105 -0
  86. package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
  87. package/scripts/test_map_postprocess_lanes.sh +143 -0
  88. package/scripts/test_marker_affected_lanes.sh +93 -0
  89. package/scripts/test_marker_crossfamily_lanes.sh +90 -6
  90. package/scripts/test_marker_oracle_lanes.sh +136 -0
  91. package/scripts/test_outbound_query_hook_lanes.sh +433 -0
  92. package/scripts/test_outbound_query_lanes.sh +87 -0
  93. package/scripts/test_pipe_verdict_guard_lanes.sh +26 -0
  94. package/scripts/test_preprep_diagram_lanes.sh +87 -0
  95. package/scripts/test_preprep_drift_anchor.sh +3 -3
  96. package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
  97. package/scripts/test_probe_live_eval_lanes.sh +437 -0
  98. package/scripts/test_proposal_hook_lanes.sh +22 -1
  99. package/scripts/test_push_zone_lanes.sh +304 -0
  100. package/scripts/test_residency_closure_lanes.sh +70 -0
  101. package/scripts/test_sim_isolated_run_lanes.sh +119 -0
  102. package/scripts/test_utterance_intake_lanes.sh +414 -0
  103. package/scripts/test_worktree_reclaim_lanes.sh +70 -0
  104. package/scripts/transcript_utterances.py +222 -0
  105. package/scripts/utterance_intake.sh +424 -0
  106. package/scripts/validate_yaml.sh +27 -0
  107. package/scripts/worktree_reclaim.sh +95 -0
  108. package/templates/.git-hooks/pre-commit +353 -1
  109. package/templates/.git-hooks/pre-push +91 -0
  110. package/templates/RED_TEAM_REPORT.md +49 -0
  111. package/templates/settings.PreToolUse.snippet.json +65 -1
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env bash
2
+ # outbound_query_guard.sh — 나가는 질의의 **위생 린트(accidental-leak lint)**.
3
+ # 🟥 **보안 통제가 아니다.** cross-family(codex, 2026-08-21) 지적: 토큰 분할·동의어·경로 일부·
4
+ # base64 는 전부 통과하고, 이 가드는 **어떤 outbound 실행기에도 배선돼 있지 않다.** 즉 «우연한
5
+ # 유출»은 잡고 «의도적 우회»는 못 잡는다. 보안 경계라 부르려면 실제 실행기에 강제 연결하고
6
+ # 정규화·승인·로깅의 우회 모델을 따로 설계해야 한다 — 하지 않았다. 그렇게 문서화한다.
7
+ #
8
+ # ── 왜 (챔버 런 consolidation-path-gate, S#6) ──
9
+ # 세션이 «세계에 물어본다» 를 하려면 문제를 이름으로 불러야 한다. 그 이름이 내부 자산명이면
10
+ # 질의 자체가 **유출**이다. 후보 스펙에 이 가드가 0줄이었고 블라인드 challenger 가 S 등급으로 지목했다.
11
+ # `CLAUDE.md §Field-Harness Diagnostic` 의 절대 규칙: 내부 자산명·경로는 로그·코멘트·붙여넣기에도
12
+ # 안 나간다. 외부 질의는 그 목록의 «붙여넣기» 와 같은 급이고, **되돌릴 수 없다.**
13
+ #
14
+ # ── 새 로직 0줄 ──
15
+ # `scripts/psa_scan_lib.sh` 가 이미 «패턴 두 층 로딩 · 행 검증 · 토큰 판정» 을 소유한다. 그 헤더가
16
+ # caller 의 몫을 명시했다: **무엇을 스캔하는가**, 그리고 **계기 불완전 시 degrade 방향**.
17
+ # 이 파일은 그 둘만 정한다 — 스캔 대상 = 질의 문자열, degrade = **fail-closed**.
18
+ #
19
+ # ── degrade 가 왜 block 인가 (커밋 caller 와 반대) ──
20
+ # 같은 라이브러리를 쓰는 pre-commit 은 override 부재 시 **경고**한다 — 커밋은 로컬이고 되돌릴 수
21
+ # 있으니까. 외부 질의는 push/publish 와 같은 부류다: 나가면 끝이다.
22
+ # `CLAUDE.md §Irreversibility Gates` 의 표면-클래스 degrade 불변식 그대로.
23
+ #
24
+ # Usage: bash scripts/outbound_query_guard.sh "<질의 문자열>"
25
+ # echo "<질의>" | bash scripts/outbound_query_guard.sh
26
+ # Exit: 0 = clean(내보내도 된다) · 1 = HIT(차단) · 3 = NOT SCANNED(계기 불완전 → 차단)
27
+ # Override: OUTBOUND_QUERY_OK=1 — 명시 승인. 로그를 남긴다. 🟥 유출이 없다고 «확인했을 때만».
28
+ set -uo pipefail
29
+ FH="$(cd "$(dirname "$0")/.." && pwd)"
30
+ # 🟥 주입 가능하게 둔다 — cross-family 가 «레인이 실물 라이브러리를 옮긴다» 를 HIGH 로 지목했다.
31
+ # 주입점이 있으면 레인이 레포를 안 건드리고, 오염 상태 분기에도 도달할 수 있다.
32
+ LIB="${PSA_LIB_FILE:-$FH/scripts/psa_scan_lib.sh}"
33
+ DEFAULTS="${PSA_DEFAULTS_FILE:-$FH/.claude/rules/.public-surface-patterns.defaults}"
34
+ OVERRIDE="${PSA_OVERRIDE_FILE:-$FH/.claude/rules/.public-surface-patterns}"
35
+ # 🟥 주입점 하나 더 — **같은 논거의 네 번째 적용**이다(위 세 개가 «레인이 레포를 안 건드리게»
36
+ # 있는 것과 동일). 실측 2026-09-05: 이 로그 경로가 박혀 있어서 L4 는 ⓐ 매 selfcheck 마다 실제
37
+ # 레포의 tracks/_meta 에 행을 쌓았고 ⓑ tracks/ 가 없는 트리(= npm 설치본)에서는 append 가
38
+ # 조용히 실패해 **`+1` 단언이 그대로 빨개졌다**. 즉 이 가드를 출하하려면 이 줄이 먼저다.
39
+ # 기본값은 안 바뀐다.
40
+ # ⚠️ 우회 채널로 읽지 마라: OUTBOUND_QUERY_OK 자체가 이미 env 로 켜는 것이라, 로그 경로를
41
+ # 옮기는 것이 «없던 우회»를 만들지는 않는다. 다만 «강행은 기록된다»는 성질이 env 하나에
42
+ # 더 의존하게 된 것은 사실이고, 그건 잔여로 이름 붙인다.
43
+ OVERRIDE_LOG="${OUTBOUND_QUERY_LOG:-$FH/tracks/_meta/.outbound_query_override_log}"
44
+
45
+ Q="${1:-}"; [ -n "$Q" ] || Q="$(cat)"
46
+ if [ -z "${Q// /}" ]; then echo " ⏭ 빈 질의 — 스캔할 것이 없다" >&2; exit 0; fi
47
+
48
+ if [ ! -r "$LIB" ]; then
49
+ echo "🚫 OUTBOUND BLOCKED — 스캐너 라이브러리 부재: $LIB" >&2
50
+ echo " 계기가 없으면 «유출 없음» 이 아니라 **미측정**이다. 비가역 표면이라 막는다." >&2
51
+ exit 3
52
+ fi
53
+ # shellcheck source=/dev/null
54
+ . "$LIB"
55
+
56
+ psa_load "$DEFAULTS" "$OVERRIDE" >/dev/null 2>&1 || true
57
+ # 🟥 **반환값이 아니라 상태 변수를 본다.** psa_scan_lib.sh 헤더가 이 함정을 명시한다:
58
+ # "A caller that treats PSA_BAD_ROWS>0 or PSA_DEFAULTS_OK=0 as 'clean' has a hole"
59
+ # 초판이 정확히 그 caller 였고 — `psa_load` 의 rc 만 보고 defaults 부재를 통과시켰다.
60
+ # known-pair 레인 L3 가 잡았다(`defaults 부재 → got=0 expect=3`). 레인이 없었으면 조용히 fail-open.
61
+ # 🟥 상태값은 `case` 로 본다. `[ -ne ]` 는 비수치 값에서 셸 에러 후 조건 false 로 흘러
62
+ # **fail-open** 이 된다 — 공유 라이브러리가 같은 결함을 이미 겪고 `case` 로 막아뒀다(psa_scan_lib.sh).
63
+ case "${PSA_DEFAULTS_OK:-}" in
64
+ 1) ;;
65
+ *)
66
+ echo "🚫 OUTBOUND BLOCKED — 공용 패턴층(defaults)이 없거나 비었다: $DEFAULTS" >&2
67
+ echo " 라이브러리 규정: 이 상태는 «설정 선택» 이 아니라 **BROKEN INSTRUMENT** 다." >&2
68
+ echo " fail-closed: 커밋 caller 는 경고하지만 외부 질의는 되돌릴 수 없다." >&2
69
+ exit 3 ;;
70
+ esac
71
+ case "${PSA_BAD_ROWS:-}" in
72
+ 0) ;;
73
+ *) echo "🚫 OUTBOUND BLOCKED — 패턴 파일 형식오류 행 '${PSA_BAD_ROWS:-?}' — 부분 로드는 미측정이다" >&2
74
+ exit 3 ;;
75
+ esac
76
+ # 🟥 **운영자 내부층(override)이 없으면 clean 을 말할 수 없다** (cross-family HIGH#1).
77
+ # 라이브러리 규정상 HIGH 내부 리터럴은 그 층에 산다. defaults 만으로 통과시키면 «보편 패턴만
78
+ # 본 것» 을 «내부 토큰 없음» 으로 렌더한다 — 초판이 그 caller 였다.
79
+ case "${PSA_OVERRIDE_PRESENT:-}" in
80
+ 1) ;;
81
+ *) echo "🚫 OUTBOUND BLOCKED — 운영자 내부 패턴층(override) 부재: $OVERRIDE" >&2
82
+ echo " defaults 만으로는 «내부 토큰 없음» 을 말할 수 없다 — 미측정이다." >&2
83
+ exit 3 ;;
84
+ esac
85
+ # 🟥 계기 생사 — 라이브러리가 카나리아 자체검증을 갖고 있다. 「초록인데 대상을 안 잰다」를 막는다.
86
+ if ! psa_require_live >/dev/null 2>&1; then
87
+ echo "🚫 OUTBOUND BLOCKED — psa_require_live 실패: 스캐너가 알려진 토큰도 못 잡는다" >&2
88
+ echo " 계기가 죽었다. 통과가 아니라 **미측정**이다." >&2
89
+ exit 3
90
+ fi
91
+
92
+ # 🟥 **개행·탭을 먼저 죽인다** (cross-family HIGH#3). `psa_scan_tagged` 는 줄마다 `path<TAB>body`
93
+ # 로 읽으므로, 질의에 개행+탭이 있으면 **토큰이 path 필드로 들어가 스캔 대상에서 빠진다.**
94
+ # 실제 우회 경로였다. 한 레코드로 눌러 넣는다.
95
+ Q_FLAT="$(printf '%s' "$Q" | tr '\n\t\r' ' ')"
96
+ # 🟥 P3 동형: 조건문 안의 대입이라야 `set -e` 아래서 안 죽는다.
97
+ if OUT="$(printf 'outbound-query\t%s' "$Q_FLAT" | psa_scan_tagged 2>&1)"; then RC=0; else RC=$?; fi
98
+ # 🟥 P2 (cross-family 3라운드): 초판은 «비영이면 override 로 통과» 였다. 그러면 **rc=3 미측정**
99
+ # 까지 승인된 것처럼 지나간다 — pre-commit·pre-push 에서 닫은 바로 그 구멍이 **세 번째
100
+ # 호출부에 그대로** 있었고, 나는 그것을 「호출부 셋 다 닫혔다」고 적었다(전수 주장이 틀렸다).
101
+ # override 는 «봤고 괜찮다» 는 뜻이라 **본 것(rc=1)에만** 쓸 수 있다. 안 본 것(rc=3)엔 못 쓴다.
102
+ # 🟥 R4-4/5: 판정점을 **하나로** 만든다. 초판은 «rc=3 선차단» + «override 에 `-le 1` 조건»
103
+ # 두 겹이었는데, 앞이 막아버려 뒤 술어는 **되돌려도 레인이 안 빨개졌다**(장식 앵커).
104
+ # 그리고 rc=2 는 어느 쪽도 안 잡아 **«유출 발견»(exit 1)으로 오분류**됐다 — 안 본 것을
105
+ # 본 것으로 렌더하는 방향이다. 「측정된 값」은 0 과 1 뿐이고 **나머지는 전부 미측정**이다.
106
+ case "$RC" in
107
+ 0|1) ;;
108
+ *) echo "🚫 OUTBOUND BLOCKED — 스캐너가 측정값을 안 냈다 (rc=$RC). NOT SCANNED 는 깨끗함이 아니다." >&2
109
+ echo " OUTBOUND_QUERY_OK 로도 통과 못 한다 — 승인은 «봤다» 는 뜻이고, 이건 안 본 것이다." >&2
110
+ echo "$OUT" >&2
111
+ exit 3 ;;
112
+ esac
113
+ if [ "$RC" -ne 0 ] || [ -n "$OUT" ]; then
114
+ # 여기 도달했다는 것은 위 `case` 에 의해 RC ∈ {0,1} 이 **구조적으로 보장**된 상태다.
115
+ # 초판의 `&& [ "$RC" -le 1 ]` 는 그래서 중복이었고, 중복은 앵커가 안 붙는다 — 지운다.
116
+ if [ "${OUTBOUND_QUERY_OK:-0}" = "1" ]; then
117
+ printf '%s\tOUTBOUND_QUERY_OK\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${Q:0:60}" \
118
+ >> "$OVERRIDE_LOG" 2>/dev/null || true
119
+ echo "⚠️ OUTBOUND — 히트가 있었으나 OUTBOUND_QUERY_OK=1 로 강행됨 (로그 기록됨)" >&2
120
+ echo "$OUT" >&2
121
+ exit 0
122
+ fi
123
+ echo "🚫 OUTBOUND BLOCKED — 나가는 질의에 내부 토큰이 있다:" >&2
124
+ echo "$OUT" >&2
125
+ echo " 고쳐라: 문제를 **일반 어휘**로 다시 써라 (내부 이름 대신 그 이름이 가리키는 «형태»)." >&2
126
+ echo " 예) «우리 X 게이트가…» → «커밋 전에 근거 필드를 강제하는 훅 패턴»" >&2
127
+ echo " 확인 후 강행: OUTBOUND_QUERY_OK=1 bash scripts/outbound_query_guard.sh \"<질의>\"" >&2
128
+ exit 1
129
+ fi
130
+ echo " ✅ outbound clean — 내부 토큰 없음" >&2
131
+ exit 0
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env bash
2
+ # outbound_query_hook.sh — PreToolUse(WebSearch|WebFetch): the WIRING for the outbound-query
3
+ # hygiene lint. 🟥 This is the FIRST hook in this repo that can emit a `deny`, so its degrade
4
+ # direction is the design, not a footnote.
5
+ #
6
+ # ── WHY A HOOK, AND WHY NOW ────────────────────────────────────────────────────────────────────
7
+ # `scripts/outbound_query_guard.sh` has owned this lint since 2026-08-21 and its own header says
8
+ # it "is wired to no outbound executor at all". A lint with zero callers is prose in a .sh file
9
+ # ([[feedback_built_but_not_wired]]); the question «did it work» was answered by EXISTENCE, which
10
+ # is the half-externalization shape ([[feedback_half_externalization_slot_without_consumer]]).
11
+ # The session's own WebSearch/WebFetch call IS the outbound executor, and PreToolUse is the only
12
+ # place that sits ON that path — a repo scanner never reads an interactively-composed query
13
+ # ([[feedback_instrument_not_on_the_path]], same reasoning as pipe_verdict_guard's).
14
+ #
15
+ # ── WHY IT IS NOT «just run the guard» ─────────────────────────────────────────────────────────
16
+ # The guard fail-closes (exit 3) when the operator's gitignored override layer is absent. That is
17
+ # RIGHT for a CLI the operator types, and WRONG for a hook: in a consumer install that layer is
18
+ # absent BY CONSTRUCTION, so importing the guard's contract would deny every consumer's WebSearch.
19
+ # CLAUDE.md: «a gate that blocks every new install is not a strict gate, it is a bypass trainer»,
20
+ # and the bypass it trains is disabling hooks — the same channel the destructive-op and
21
+ # confidentiality gates ride on. So the verdict is split BY LAYER:
22
+ #
23
+ # override layer hit → deny the operator's own internal literals. This is the leak class
24
+ # the guard exists for, and it is irreversible once sent.
25
+ # defaults layer hit → advisory universal shapes (emails, home paths, key formats). FP-prone,
26
+ # and it is ALL a consumer has — blocking on it would be the
27
+ # over-block above.
28
+ # no override present → advisory only, plus a once-per-session UNCALIBRATED notice: a
29
+ # defaults-only scan CANNOT say «no internal tokens», it can
30
+ # only say «no universal shapes» ([[feedback_not_found_is_not_zero_family]]).
31
+ # instrument incomplete → deny (lib absent · defaults broken · dropped pattern rows · dead
32
+ # canary · scanner returned a non-verdict). NOT SCANNED is not
33
+ # clean, and this surface is irreversible
34
+ # (CLAUDE.md §Irreversibility Gates: fail CLOSED).
35
+ # not an FH checkout → silent applicability is MECHANICAL (is the tracked defaults file
36
+ # here?), never self-judged — and in that tree we create nothing.
37
+ #
38
+ # 🟥 THE LAYER IS DECIDED BY COUNTS, NOT BY A BOOLEAN. Scan twice — full set, then defaults-only —
39
+ # and compare hit COUNTS. With booleans, «both layers hit» is indistinguishable from «defaults
40
+ # hit», which silently downgrades a real deny to an advisory. `n_full < n_def` is impossible
41
+ # (the full stream is a superset) and is therefore treated as instrument anomaly → deny.
42
+ #
43
+ # 🟥 THE OUTPUT MUST NEVER CARRY THE TOKEN VALUE. psa_scan_tagged prints `SEV leak — path: 'tok'`.
44
+ # Echoing that into a deny reason, a systemMessage, or the event log would make the leak guard
45
+ # the leak channel — and CLAUDE.md's residency rule names logs and comments explicitly. Only
46
+ # the COUNT and the SEVERITY LABEL leave this script, and a label that is not plain ASCII
47
+ # letters is rendered `?`. Lane H10 asserts the fixture token appears on no output surface.
48
+ #
49
+ # ── STDIN SHAPE: WHY KEY NAMES ARE NOT HARDCODED ───────────────────────────────────────────────
50
+ # The official hooks reference (code.claude.com/docs/en/hooks-guide.md, read 2026-09-05) documents
51
+ # `tool_name` and `tool_input` but does NOT document WebSearch's / WebFetch's `tool_input` keys.
52
+ # Hardcoding `query` / `url` / `prompt` on an unverified schema fails SILENTLY OPEN the day the
53
+ # schema changes. So every STRING VALUE in `tool_input` is collected recursively and scanned as
54
+ # one record. The key NAMES (schema, never user content) are recorded in the event log so the
55
+ # real shape becomes measured rather than assumed.
56
+ # MEASURED 2026-09-05, disposable clone, sonnet, real WebSearch call: the logged key column read
57
+ # `query` — so WebSearch's key is `query` today. Recorded as an OBSERVATION, not as a contract to
58
+ # depend on: the scan still walks every string value, so a renamed or added key stays covered.
59
+ #
60
+ # ── WHAT A CLEAN-CLONE RUN FOUND THAT THE LANES COULD NOT ──────────────────────────────────────
61
+ # First run: both evidence files landed as UNTRACKED paths and the runner flagged tree
62
+ # contamination — .gitignore had explicit entries for the other hooks' event files and none for
63
+ # these two. Every lane passed throughout; nothing in a fixture tree can see a repo's .gitignore.
64
+ # That is the «adversarial review is not a substitute for first use» shape
65
+ # ([[feedback_adversarial_review_not_substitute_for_first_use]]). Fixed in .gitignore, and the
66
+ # consumer-facing twin of it is called out in templates/settings.PreToolUse.snippet.json.
67
+ #
68
+ # ── DEGRADE CONTRACT ───────────────────────────────────────────────────────────────────────────
69
+ # unparseable payload / python3 absent → SILENT exit 0. A dead interpreter must not block tool
70
+ # calls (same contract as pipe_verdict_guard / proposal_hook). This is the one place where
71
+ # «cannot measure» does not deny, and it is deliberate: the failure is in OUR harness, it is
72
+ # loud in no direction, and denying on it would take the session's web access away whenever
73
+ # python3 hiccups. Named as a residual, not defended as complete.
74
+ # Every other «cannot measure» state DENIES, because there the scanner ran and did not certify.
75
+ # exit code is ALWAYS 0 — the JSON carries the verdict (docs: "Exit 0 with JSON output: parsed
76
+ # JSON controls behavior"). exit 2 would work too but discards the structured reason.
77
+ #
78
+ # Opt out on one call by putting `# noqa: outbound` in the query. 🟥 That channel is gameable —
79
+ # which is exactly why it writes a log row. An invisible bypass is worse than a recorded one.
80
+ #
81
+ # Wiring: templates/settings.PreToolUse.snippet.json (SIXTH GUARD). Lanes:
82
+ # bash scripts/test_outbound_query_hook_lanes.sh
83
+ # Manual smoke:
84
+ # printf '%s' '{"tool_name":"WebSearch","tool_input":{"query":"hello"}}' | bash scripts/outbound_query_hook.sh
85
+ set -uo pipefail
86
+
87
+ # 🟥 TWO ROOTS, AND THEY ARE NOT THE SAME ROOT. The library ships NEXT TO this script, so it is
88
+ # resolved from $0 — a hook is invoked by absolute path and must find its own scanner regardless
89
+ # of what CLAUDE_PROJECT_DIR points at. The pattern layers and the event log belong to the
90
+ # PROJECT being guarded, so they are resolved from CLAUDE_PROJECT_DIR. In a normal install the
91
+ # two coincide; conflating them made every lane fixture (project root ≠ repo root) deny on
92
+ # "library absent" — caught by the lanes on the first run, which is what they are for.
93
+ SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
94
+ FH="${CLAUDE_PROJECT_DIR:-}"
95
+ [ -n "$FH" ] || FH="$(cd "$SELF_DIR/.." && pwd)"
96
+ # Injection points — the lanes must not move the real library or the real pattern files
97
+ # (test_outbound_query_lanes.sh L7 learned that the hard way).
98
+ LIB="${PSA_LIB_FILE:-$SELF_DIR/psa_scan_lib.sh}"
99
+ DEFAULTS="${PSA_DEFAULTS_FILE:-$FH/.claude/rules/.public-surface-patterns.defaults}"
100
+ OVERRIDE="${PSA_OVERRIDE_FILE:-$FH/.claude/rules/.public-surface-patterns}"
101
+ EVDIR="$FH/.claude"
102
+ EVFILE="$EVDIR/.outbound_hook_events.tsv"
103
+ SENTINEL="$EVDIR/.outbound_hook_uncalibrated_notice"
104
+ # A path that can never exist: /dev/null is not a directory, so any child of it is ENOTDIR.
105
+ NO_OVERRIDE="/dev/null/__fh_no_override__"
106
+
107
+ TOOL=""; SESS=""; KEYS=""; FLAGS=""; QTEXT=""
108
+
109
+ _py_extract() { cat <<'PY'
110
+ import json,sys,re
111
+ def out(a,b,c,f,d):
112
+ # ONE line, exactly four TABs. `text` is flattened below, so no field can contain a TAB and
113
+ # the split in bash is exact. A temp file used to carry this; mktemp's failure branch was a
114
+ # silent `exit 0` on an outbound call (degrade_direction_scan S1, 2026-09-05) — removing the
115
+ # file removed the branch rather than arguing about it.
116
+ sys.stdout.write("OQH2\t"+a+"\t"+b+"\t"+c+"\t"+f+"\t"+d+"\n"); sys.exit(0)
117
+ try:
118
+ d=json.load(sys.stdin)
119
+ except Exception:
120
+ out("","","","-","")
121
+ if not isinstance(d,dict): out("","","","-","")
122
+ tn=re.sub(r"[^A-Za-z0-9_-]","",str(d.get("tool_name","") or ""))[:32]
123
+ sid=re.sub(r"[^A-Za-z0-9_-]","",str(d.get("session_id","") or ""))[:64]
124
+ ti=d.get("tool_input",{})
125
+ # A non-object tool_input is NOT replaced by {} — a bare string is still an outbound value and is
126
+ # scanned like any other (codex finding 1, 2026-09-05). Key NAMES: the log carries only the KNOWN
127
+ # schema names; anything else renders as `other`, so a token used as a key never lands in the
128
+ # event file (finding 4) — and keys are scanned too (walk below), because they leave as well.
129
+ keys=""
130
+ if isinstance(ti,dict):
131
+ keys=",".join(sorted(set(k if k in ("query","url","prompt") else "other" for k in map(str,ti.keys()))))
132
+ vals=[]; trunc=[False]
133
+ def walk(x,depth):
134
+ # Past the limits the extractor STOPS and SAYS SO — a token below the cut would otherwise leave
135
+ # as "clean" (findings 2·3). bash turns the flag into a deny (unmeasured), never a partial scan.
136
+ if depth>8 or len(vals)>4096:
137
+ trunc[0]=True; return
138
+ if isinstance(x,str): vals.append(x)
139
+ elif isinstance(x,dict):
140
+ for k,v in x.items():
141
+ if isinstance(k,str): vals.append(k)
142
+ walk(v,depth+1)
143
+ elif isinstance(x,list):
144
+ for v in x: walk(v,depth+1)
145
+ walk(ti,0)
146
+ text=re.sub(r"[\r\n\t]"," "," ".join(vals))
147
+ out(tn,sid,keys,("T" if trunc[0] else "-"),text)
148
+ PY
149
+ }
150
+
151
+ _py_json() { cat <<'PY'
152
+ import json,sys
153
+ mode=sys.argv[1]; msg=sys.argv[2]
154
+ h={"hookEventName":"PreToolUse"}
155
+ if mode=="deny":
156
+ h["permissionDecision"]="deny"; h["permissionDecisionReason"]=msg
157
+ else:
158
+ h["additionalContext"]=msg
159
+ print(json.dumps({"hookSpecificOutput":h,"systemMessage":msg},ensure_ascii=False))
160
+ PY
161
+ }
162
+
163
+ _log() { # $1=verdict $2=layer
164
+ mkdir -p "$EVDIR" 2>/dev/null || return 1
165
+ # Returns 1 when the row could not be written. Only the noqa path acts on that (see below):
166
+ # for every other verdict a lost log line is a lost record, not a lost block.
167
+ if printf '%s\t%s\t%s\t%s\t%s\n' \
168
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${TOOL:-?}" "$1" "$2" "${KEYS:-}" >> "$EVFILE" 2>/dev/null
169
+ then return 0; else return 1; fi
170
+ }
171
+
172
+ # 🟥 A DENY THAT CANNOT BE RENDERED MUST NOT BECOME A SILENT ALLOW.
173
+ # The first build ended this function with `|| true`, so a failed JSON build emitted nothing —
174
+ # and "no output" is how this hook says ALLOW. degrade_direction_scan S1 flagged the line and
175
+ # it was a real default-toward-PASS on an irreversible surface, not a false positive.
176
+ # Fallback is the documented second channel: "Exit 2: blocks action, uses stderr as reason".
177
+ # An ADVISORY that cannot be rendered is a lost warning, not a lost block — it stays exit 0.
178
+ # OQH_PY exists so that branch is ANCHORED rather than asserted: the lane points it at a
179
+ # non-interpreter while the parse above still runs on the real python3. Same justification as
180
+ # PSA_LIB_FILE in outbound_query_guard.sh — a branch no lane can reach is decorative.
181
+ _emit() { # $1=deny|advisory $2=message
182
+ local _j
183
+ _j="$("${OQH_PY:-python3}" -c "$(_py_json)" "$1" "$2" 2>/dev/null)" || _j=""
184
+ if [ -n "$_j" ]; then printf '%s\n' "$_j"; return 0; fi
185
+ if [ "$1" = deny ]; then
186
+ printf '%s\n' "$2" >&2
187
+ exit 2
188
+ fi
189
+ return 0
190
+ }
191
+
192
+ # Severity histogram with NO token values. The label is the pattern file's first column, so it is
193
+ # operator-authored text: anything that is not 1-8 plain ASCII letters is rendered `?`.
194
+ _sev_summary() { # $1 = raw scan output
195
+ printf '%s\n' "$1" \
196
+ | grep -F ' leak — ' 2>/dev/null \
197
+ | awk '{ s=$2; if (s=="HIGH" || s=="MED" || s=="LOW") print s; else print "?" }' \
198
+ | sort | uniq -c \
199
+ | awk '{ printf "%s x%s ", $2, $1 }'
200
+ return 0
201
+ }
202
+ _hits() { # $1 = raw scan output → count of reportable hit lines
203
+ # noqa: degrade — `grep -c` exits 1 when the count is ZERO, which is the normal clean case, and
204
+ # it still PRINTS "0". The `|| true` protects the function's rc, not its value; a genuine grep
205
+ # error prints NOTHING, and the empty value fails closed at the `case` guard below. Verified by
206
+ # reading both paths, 2026-09-05; the guard it lands on is labelled unanchored there.
207
+ printf '%s\n' "$1" | grep -cF ' leak — ' 2>/dev/null || true # noqa: degrade (see above)
208
+ }
209
+
210
+ _deny_unmeasured() { # $1 = what is incomplete (no token values, ever)
211
+ _log deny -
212
+ _emit deny "🚫 outbound 차단 — 미측정(계기 불완전: $1). 나가는 질의는 되돌릴 수 없으므로 «못 쟀다»를 «깨끗하다»로 접지 않는다(CLAUDE.md §Irreversibility Gates). 고쳐야 할 것: 위 계기. 확인 후 강행하려면 질의에 '# noqa: outbound' 를 넣어라 — 통과하지만 로그에 남는다."
213
+ exit 0
214
+ }
215
+
216
+ # ── 1. parse ───────────────────────────────────────────────────────────────────────────────────
217
+ # python3 absent / crashed / payload unparseable → empty → silent exit 0. That is the ONE named
218
+ # fail-open in this script (see DEGRADE CONTRACT above): a dead interpreter is our defect, not the
219
+ # session's, and taking web access away on it would train the hook off.
220
+ _META="$(python3 -c "$(_py_extract)" 2>/dev/null)"
221
+ [ -n "$_META" ] || exit 0
222
+ # 🟥 Do NOT use `IFS=$'\t' read` here: TAB is an IFS whitespace character, so consecutive
223
+ # separators MERGE and an empty field SHIFTS every later field left — a session with no
224
+ # session_id would silently read its KEYS as SESS ([[feedback_ifs_read_collapses_empty_fields]]).
225
+ # Positional splits cannot collapse. (An earlier draft said "exact-count validation, then
226
+ # positional splits"; the count is gone — see the next paragraph — and leaving that sentence
227
+ # would be a rule describing a machine it no longer has.)
228
+ # 🟥 VALIDATE WITH A PREFIX, NOT BY COUNTING — the count was a DoS that degraded into a BYPASS.
229
+ # The first build validated the field count with `_TABS="${_META//[!$'\t']/}"`. Measured on
230
+ # bash 3.2 (macOS /bin/bash, the shell this hook actually runs under): that ONE substitution
231
+ # takes **47.7s on a 10,000-char line**, and the whole hook went 0.23s @0.5KB → 6.6s @5KB →
232
+ # 49.7s @10KB. With `"timeout": 5` in settings the hook is KILLED, and a killed PreToolUse hook
233
+ # is a non-blocking error — **the tool proceeds**. So a long enough query walked straight past
234
+ # the guard: a bypass by PADDING, needing no override and leaving no row. The library was never
235
+ # the cost (psa_scan_tagged does the same 10,000 chars in 0.15s); the guard was.
236
+ # The extractor already builds the line and controls every field, so the invariant belongs
237
+ # there: it emits a magic first field, and bash checks a PREFIX (O(1)) instead of scanning the
238
+ # whole string. Positional splits are 0.02s at the same size. Anchored by lane H23.
239
+ case "$_META" in "OQH2"$'\t'*) ;; *) exit 0 ;; esac
240
+ _R0="${_META#*$'\t'}"
241
+ TOOL="${_R0%%$'\t'*}"; _R1="${_R0#*$'\t'}"
242
+ SESS="${_R1%%$'\t'*}"; _R2="${_R1#*$'\t'}"
243
+ KEYS="${_R2%%$'\t'*}"; _R3="${_R2#*$'\t'}"
244
+ FLAGS="${_R3%%$'\t'*}"; QTEXT="${_R3#*$'\t'}"
245
+
246
+ # ── 2. scope: only the two outbound tools, and nothing else is logged ──────────────────────────
247
+ case "$TOOL" in WebSearch|WebFetch) ;; *) exit 0 ;; esac
248
+
249
+ # ── 3. applicability — MECHANICAL, never self-judged (CLAUDE.md §Surface-Class Degrade) ────────
250
+ # The tracked defaults layer ships with this repo. Absent ⇒ this is not an FH-derived checkout,
251
+ # so the guard has no target here. `applicable-but-tooling-down` is handled below, not here.
252
+ # 🟥 THIS RUNS BEFORE THE OPT-OUT, and the order is load-bearing. With the opt-out first, a
253
+ # `# noqa: outbound` query in a NON-FH tree would try to write a log row, and the fail-closed
254
+ # rule below would then DENY a call this hook has no business judging at all — over-blocking a
255
+ # stranger's repo. Applicability first means the bypass rule only binds where the guard is live.
256
+ # N/A is silent AND unrecorded — an earlier build appended an `na` row whenever `.claude/` already
257
+ # existed, which is every Claude Code project, so a user-level hook wrote into stranger repos
258
+ # (codex finding 7, 2026-09-05). Lane H4b now asserts the file is NOT created.
259
+ [ -r "$DEFAULTS" ] || exit 0
260
+
261
+ # ── 4. opt-out, recorded ───────────────────────────────────────────────────────────────────────
262
+ if printf '%s' "$QTEXT" | grep -qE '#[[:space:]]*noqa:?[[:space:]]*outbound' 2>/dev/null; then
263
+ # 🟥 THE RECORD IS THE PRICE OF THE BYPASS. This channel is gameable by design; what keeps it
264
+ # honest is that it leaves a row. If the row cannot be written, the bypass becomes INVISIBLE,
265
+ # and an unrecorded bypass on an irreversible surface is worse than no bypass at all — so the
266
+ # opt-out itself fails closed here. Every other verdict tolerates a lost log line.
267
+ _log noqa - && exit 0
268
+ _deny_unmeasured "opt-out(# noqa: outbound)을 기록할 수 없다 — 기록 못 하는 우회는 보이지 않는 우회다"
269
+ fi
270
+
271
+ # ── 5. nothing to scan ─────────────────────────────────────────────────────────────────────────
272
+ # 🟥 The extractor reports TRUNCATION (nesting deeper than 8, or more than 4096 string values) as a
273
+ # flag instead of scanning what it could reach — a token below the cut would leave as "clean"
274
+ # (codex findings 2·3). Unmeasured ⇒ deny, like every other incomplete-instrument branch. H26/H27.
275
+ case "$FLAGS" in *T*) _deny_unmeasured "입력 구조가 추출 한계(깊이 8 · 값 4096)를 넘어 일부를 못 봤다" ;; esac
276
+ # Emptiness is a bash pattern, not an external `tr`: on a PATH without `tr` the substitution came
277
+ # back empty and that read as "nothing to scan" = clean allow (codex finding 6). Lane H30.
278
+ case "$QTEXT" in *[![:space:]]*) ;; *) _log clean -; exit 0 ;; esac
279
+
280
+ # ── 6. instrument ──────────────────────────────────────────────────────────────────────────────
281
+ [ -r "$LIB" ] || _deny_unmeasured "스캐너 라이브러리 부재"
282
+ # shellcheck source=/dev/null
283
+ . "$LIB" 2>/dev/null || _deny_unmeasured "스캐너 라이브러리 로드 실패"
284
+ # Pin the allowlist root: without it psa_pair_allowlisted falls back to `git rev-parse` or `.`,
285
+ # which in a hook can resolve to a DIFFERENT repo than the one being scanned.
286
+ PSA_REPO_ROOT="$FH"; export PSA_REPO_ROOT
287
+
288
+ # 🟥 FLATTENING LIVES IN THE EXTRACTOR, NOT HERE — measured, and the belt was dead.
289
+ # psa_scan_tagged reads `path<TAB>body` per LINE, so a newline+tab in the query would push the
290
+ # token into the PATH field and out of the scan (the bypass test_outbound_query_lanes.sh L9
291
+ # pins for the CLI). A `tr` belt stood here; a revert probe removed it and lane H8 stayed
292
+ # GREEN (mutant M7) — the extractor's `re.sub` had already done it. Removing THAT (M8) turned
293
+ # H8 into `none`, i.e. SILENT pass-through, because the 4-line read protocol below also
294
+ # depends on the text being one line. One owner, anchored by H8. The duplicate is deleted
295
+ # rather than kept as reassurance ([[feedback_anchor_can_be_decorative]]).
296
+ QFLAT="$QTEXT"
297
+
298
+ _load_and_check() { # $1=defaults $2=override ; state via case, never [ -ne ] (non-numeric → fail-open)
299
+ psa_load "$1" "$2" >/dev/null 2>&1 || true
300
+ case "${PSA_DEFAULTS_OK:-}" in 1) ;; *) return 1 ;; esac
301
+ case "${PSA_BAD_ROWS:-}" in 0) ;; *) return 2 ;; esac
302
+ return 0
303
+ }
304
+
305
+ _load_and_check "$DEFAULTS" "$OVERRIDE"; _lc=$?
306
+ case "$_lc" in
307
+ 0) ;;
308
+ 1) _deny_unmeasured "공용 패턴층(defaults)이 비었거나 못 읽는다" ;;
309
+ *) _deny_unmeasured "패턴 파일에 형식오류 행이 있어 검출기 집합이 부분적이다" ;;
310
+ esac
311
+ OVR="${PSA_OVERRIDE_PRESENT:-0}"
312
+ psa_require_live >/dev/null 2>&1 || _deny_unmeasured "psa_require_live 실패 — 스캐너가 알려진 토큰도 못 잡는다"
313
+
314
+ if FULL="$(printf 'outbound-query\t%s' "$QFLAT" | psa_scan_tagged 2>&1)"; then FRC=0; else FRC=$?; fi
315
+ case "$FRC" in 0|1) ;; *) _deny_unmeasured "스캐너가 측정값을 안 냈다 (rc=$FRC)" ;; esac
316
+ printf '%s' "$FULL" | grep -qF 'INSTRUMENT DEAD' 2>/dev/null && _deny_unmeasured "스캐너가 INSTRUMENT DEAD 를 보고했다"
317
+ N_FULL="$(_hits "$FULL")"
318
+
319
+ # ── 7. defaults-only re-scan → which LAYER produced the hits ───────────────────────────────────
320
+ if [ "$OVR" = 1 ]; then
321
+ _load_and_check "$DEFAULTS" "$NO_OVERRIDE"; _lc=$?
322
+ [ "$_lc" -eq 0 ] || _deny_unmeasured "defaults 전용 재로드 실패 (층 판별 불가)"
323
+ if DEFONLY="$(printf 'outbound-query\t%s' "$QFLAT" | psa_scan_tagged 2>&1)"; then DRC=0; else DRC=$?; fi
324
+ case "$DRC" in 0|1) ;; *) _deny_unmeasured "defaults 전용 스캔이 측정값을 안 냈다 (rc=$DRC)" ;; esac
325
+ N_DEF="$(_hits "$DEFONLY")"
326
+ else
327
+ DEFONLY="$FULL"; N_DEF="$N_FULL"
328
+ fi
329
+ # ⚠️ UNANCHORED, DELIBERATELY KEPT (measured 2026-09-05, mutant M11): removing both lines leaves
330
+ # all 64 lanes green. The only realistic way `_hits` returns a non-number is a broken grep, and
331
+ # `psa_require_live` above dies on that first — so this branch is unreachable today. Kept as the
332
+ # guard for a refactor that removes the canary; labelled, because an unlabelled unreachable
333
+ # guard reads as a control it is not ([[feedback_anchor_can_be_decorative]]).
334
+ case "$N_FULL" in ''|*[!0-9]*) _deny_unmeasured "히트 계수 실패(full)" ;; esac
335
+ case "$N_DEF" in ''|*[!0-9]*) _deny_unmeasured "히트 계수 실패(defaults)" ;; esac
336
+ # A superset cannot produce fewer hits than its subset. If it did, the two scans did not see the
337
+ # same instrument — that is an anomaly, and an anomaly on an irreversible surface is a deny.
338
+ [ "$N_FULL" -ge "$N_DEF" ] || _deny_unmeasured "층 비교 이상 (full=$N_FULL < defaults=$N_DEF)"
339
+
340
+ # ── 8. once-per-session UNCALIBRATED notice (consumer installs) ────────────────────────────────
341
+ _uncal_due() {
342
+ local key cur
343
+ key="${SESS:-}"; [ -n "$key" ] || key="$(date -u +%Y-%m-%d)"
344
+ cur=""; [ -r "$SENTINEL" ] && cur="$(cat "$SENTINEL" 2>/dev/null || true)"
345
+ [ "${cur%% *}" = "$key" ] && return 1
346
+ mkdir -p "$EVDIR" 2>/dev/null || true
347
+ # noqa: degrade — a failed sentinel write makes the UNCALIBRATED notice REPEAT on every call.
348
+ # That degrades toward more noise, never toward silence, so the permissive short-circuit is in
349
+ # the safe direction here. (Contrast `_log` on the noqa path, where the same shape was NOT safe
350
+ # and was changed to fail closed.)
351
+ printf '%s %s\n' "$key" "$(date -u +%Y-%m-%d)" > "$SENTINEL" 2>/dev/null || true # noqa: degrade (see above)
352
+ return 0
353
+ }
354
+ UNCAL=""
355
+ if [ "$OVR" != 1 ]; then
356
+ if _uncal_due; then
357
+ UNCAL=" 🟥 UNCALIBRATED — 운영자 내부 패턴층(.claude/rules/.public-surface-patterns)이 이 설치에 없다. 공용 층만 본 결과라 «내부 토큰 없음»은 말할 수 없다(미측정). 이 고지는 세션당 1회."
358
+ fi
359
+ fi
360
+
361
+ # ── 9. verdict ─────────────────────────────────────────────────────────────────────────────────
362
+ if [ "$N_FULL" -eq 0 ]; then
363
+ _log clean -
364
+ exit 0
365
+ elif [ "$N_FULL" -gt "$N_DEF" ]; then
366
+ _log deny override
367
+ _emit deny "🚫 outbound 차단 — 나가는 질의에 «운영자 내부 패턴층» 히트 $N_FULL 건 (심각도: $(_sev_summary "$FULL")). 🟥 토큰 값은 여기 적지 않는다 — 유출 가드가 유출 채널이 되면 안 되니까. 나간 질의는 되돌릴 수 없다. 고쳐라: 내부 이름 대신 그 이름이 가리키는 «형태»를 일반 어휘로 써라 (예: «우리 X 게이트가…» → «커밋 전에 근거 필드를 강제하는 훅 패턴»). 확인 후 강행: 질의에 '# noqa: outbound' 를 넣어라 — 통과하지만 로그에 남는다."
368
+ exit 0
369
+ else
370
+ _log advisory defaults
371
+ _emit advisory "⚠️ outbound 위생 — 나가는 질의가 «공용(defaults)» 패턴에 $N_DEF 건 걸린다 (심각도: $(_sev_summary "$DEFONLY")). 차단은 안 한다: 공용 층은 보편 형태라 오탐이 잦고, 여기서 막으면 훅을 끄게 만든다. 토큰 값은 적지 않는다 — 질의를 사람 눈으로 한 번 보고, 내부 이름이면 일반 어휘로 바꿔 다시 불러라.$UNCAL"
372
+ exit 0
373
+ fi
@@ -39,18 +39,33 @@ cd "$REPO_ROOT" || exit 1
39
39
  # scripts/sync_to_be_lanes.sh — forward-path lane suite for sync-to-be.sh, itself
40
40
  # ACCEPTED_ABSENT above; added 2026-08-14, pmh-dev#69.
41
41
  ACCEPTED_ABSENT=(
42
- # 🟥 나가는 질의 위생 린트와레인 **일부러 출하하지 않는다.**
43
- # 가드는 운영자 내부 패턴층(`.claude/rules/.public-surface-patterns`, gitignored)이 없으면
44
- # fail-closed 막는다. 층은 소비자에게 나가므로, 출하하면 **신선한 설치는 100% 차단**된다
45
- # CLAUDE.md «모든 install 막는 게이트는 엄격한 게이트가 아니라 우회 훈련기» 라 못박은
46
- # 형태다. 소비자용으로 열려면 override 부재 시의 degrade 를 따로 설계해야 하고, 안 했다.
47
- # `selfcheck.sh` 대상 부재 `_absent_subject_verdict` 빠지므로 패키지 모드에서 옳게 degrade 한다.
42
+ # 🟥 2026-09-05 outbound 가드와레인은 **이 목록에서 나갔다(= 이제 출하한다).**
43
+ # 종전 사유는 «override 층이 없으면 fail-closed 신선 설치를 100% 차단한다» 였는데,
44
+ # 문장은 **두 가지를 뭉쳤다**: 가드가 fail-closed 것은 맞지만 **신선 설치에서 그 가드를
45
+ # 부르는 것이 아무것도 없었다**(호출부 0개 그게 파일의 존재 이유였다). selfcheck
46
+ # 도는 것은 가드가 아니라 **레인**이고, 레인은 픽스처 주입점으로만 돈다.
47
+ # 실제로 막고 있던 것은 다른 것이었고, 실측으로 잡았다: 레인 L4 로그 경로를 박아 둬서
48
+ # `tracks/` 가 없는 트리(= npm 설치본)에서 «+1» 단언이 0 이 되어 **빨개졌다.** 그 한 줄을
49
+ # 주입점으로 바꾸자(OUTBOUND_QUERY_LOG) 소비자 형태 트리에서 11/11 초록이다.
50
+ # 이제 출하하는 이유: `scripts/outbound_query_hook.sh`(PreToolUse WebSearch|WebFetch)가
51
+ # 소비자에게 나가는데, 그 훅은 **WebSearch/WebFetch 만** 덮는다. Bash 로 나가는 질의
52
+ # (curl·외부 CLI)는 사람이 손으로 이 CLI 를 부르는 것 말고 커버가 없다 — 훅만 내보내고
53
+ # 가드를 빼면 소비자에게 «반쪽만» 준다.
54
+ # ⚠️ 정직한 성질 하나: override 층이 없는 소비자가 이 CLI 를 직접 부르면 exit 3(미측정)이다.
55
+ # 쓰려면 `.claude/rules/.public-surface-patterns` 를 만들어야 한다. 훅 쪽은 그 상태에서
56
+ # advisory + UNCALIBRATED 로 degrade 하므로 **소비자 세션이 막히지는 않는다.**
48
57
  # 🟥 2026-09-01 — 아래 여섯은 «출하 문서가 이름을 대지만 selfcheck 가 실행하지 않는» 것들이다.
49
58
  # 판별은 실측이다: `bash <경로>` 형태의 호출을 selfcheck 에서 센 결과 watermark 만 3회이고
50
59
  # 나머지는 0회다 — 그래서 watermark 는 files[] 로 갔고 이 여섯은 여기 남는다.
51
60
  # 🟥 소비자가 «필요로 하지 않는다»가 아니라 «이 레포의 연구·감사 계기다»가 이유다.
52
61
  # 출하하면 소비자 트리에 안 도는 스크립트가 늘고, 그게 orphan 스캔의 소음이 된다.
53
62
  "scripts/stray_path_scan.sh" # 이 레포 경로 위생 스캐너 — 소비자 트리 구조가 다르다
63
+ # 🟥 2026-09-05 — docs/map 은 레포·Pages 표면이다(렌더 산출물 ~4 MB + «노드 = 실재 경로» 계약이
64
+ # .github/·tests/ 등 출하하지 않는 경로를 가리킨다). npm 에는 README 의 포인터만 나간다 —
65
+ # 지도를 출하하면 그 레인이 소비자 트리에서 «부재」로 빨개지고, 그건 결함이 아니라 표면 차이다.
66
+ "docs/map/FH_MAP.md" # 리더용 지도 산문 — 레포/Pages 에서 읽는다
67
+ "docs/map/fh_assets.architecture.json" # 지도 소스(노드 = 레포 경로 계약) — 이 레포 계기의 입력
68
+ "scripts/test_fh_map_paths_lanes.sh" # 그 계약의 레인 — 이 레포에서만 돈다
54
69
  "scripts/test_stray_path_lanes.sh" # 위 레인
55
70
  "scripts/test_fixture_guard_lanes.sh" # 픽스처 가드 레인 — 가드 자체(fixture_guard_lib)는 출하한다
56
71
  "scripts/test_marker_first_use_lanes.sh" # 4축 마커 first-use 레인 — 이 레포 마커 규약 전용
@@ -66,9 +81,9 @@ ACCEPTED_ABSENT=(
66
81
  "scripts/round/gatecheck_qset.sh" # 같은 이유 — 회차 개시 게이트, 소비자 표면 아님 (2026-09-02 짝표 등재로 참조가 생겼다)
67
82
  "scripts/test_round_instruments_lanes.sh"
68
83
  "scripts/fixtures/isolation_assembly_BROKEN_2026-08-30_ccrun7.json" # 역사 산출물(등급표가 증거로 인용)
69
- "scripts/outbound_query_guard.sh"
70
- "scripts/test_outbound_query_lanes.sh"
71
- # 🟥 영혼(judgment-circuit) 3종 **일부러 출하하지 않는다.** 위 outbound 가드와 같은 이유다.
84
+ # 🟥 영혼(judgment-circuit) 3종 — **일부러 출하하지 않는다.**
85
+ # (종전 «위 outbound 가드와 같은 이유»라는 포인터는 2026-09-05 에 끊겼다 — outbound 는 출하로
86
+ # 갔다. 여기 사유는 그것과 무관하게 아래 자기 근거로 선다.)
72
87
  # 이 셋은 `.claude/soul_tenets.txt`(=**이 레포의** 심지 원칙 등록부)를 전제로 하는데, 그 파일은
73
88
  # 소비자에게 안 나간다 — 소비자의 tenet 은 소비자가 쓰는 것이지 우리가 주는 것이 아니다
74
89
  # (`.claude/regression/probes.md` 가 이미 같은 논리로 미출하다).
@@ -85,7 +100,10 @@ ACCEPTED_ABSENT=(
85
100
  "scripts/soul_trace.sh"
86
101
  # 🟥 sim 경로격리 레인 — 출하 안 한다. `sim_isolated_run.sh` 는 이 레포의 측정 도구이고
87
102
  # 이 레인은 «그 러너가 클론에 무엇을 써넣는가»를 본다. 소비자에게 그 러너가 없으면
88
- # 레인이 HARNESS-ERROR 로 죽고, 그건 신선 설치를 막는 형태가 된다(위 outbound 와 동일 논리).
103
+ # 레인이 HARNESS-ERROR 로 죽고, 그건 신선 설치를 막는 형태가 된다.
104
+ # ⚠️ 종전에 여기 «위 outbound 와 동일 논리» 라는 포인터가 있었는데 2026-09-05 에 끊었다 —
105
+ # outbound 는 출하로 갔고, 그 블록의 종전 사유는 실측으로 물러졌다. 이 블록의 사유는
106
+ # 그것과 무관하게 **자기 자신으로 선다**: 여기서는 대상(러너)이 소비자 트리에 정말 없다.
89
107
  "scripts/test_sim_path_isolation_lanes.sh"
90
108
  ".claude/registry/LOCAL_SKILL_REGISTRY.md"
91
109
  ".claude/regression/probes.md"
@@ -193,18 +211,39 @@ ACCEPTED_ABSENT=(
193
211
  # 경로로 적는데 **이 파일 자신이 shipped 문서**라, 「안 싣는 이유」를 설명한 행위가
194
212
  # 곧 「shipped 문서가 미출하 경로를 가리킴」이 됐다. 계기가 자기 수리를 잡은 것이고,
195
213
  # 위 블록이 예고한 형태(*전체 경로를 shipped 파일에 쓰면 팬텀이 된다*)의 **3차 재현**이다.
196
- # 주체 자신도 같은 사유로 등재한다 신규 게이트 둘은 **이 레포 전용**이다(추가 파일을
197
- # 레포 레인 코퍼스에 대고 판정한다). 소비자 트리엔 판정 대상이 없다.
198
- "scripts/residency_closure_scan.py"
199
- "scripts/test_residency_closure_lanes.sh"
214
+ # (이 문단이 이어서 「주체 자신도 같은 사유로 등재한다」고 residency_closure_scan.py
215
+ # 여기 넣었었다 등재는 2026-09-05 **철회했다**. 아래 참조.)
216
+ # 🟥 **철회 (2026-09-05) — `residency_closure_scan.py` / `test_residency_closure_lanes.sh` 가
217
+ # ACCEPTED_ABSENT 였던 이유(「이 레포 전용 감사 도구, 소비자 트리엔 판정 대상 없음」)는
218
+ # 운영자 승인 하에 뒤집혔다: `plugins/fh-meta/skills/auto-decorrelation/SKILL.md` §Step 4.5
219
+ # 가 이제 **소비자의 세션**더러 cross-family dispatch 직전마다 이 스캐너를 돌리라고 지시하고,
220
+ # `templates/.git-hooks/pre-commit` 의 `validate_crossfamily_leg` 가 그 결과(`residency=
221
+ # CLEAN|TAINTED|NOT_SCANNED(...)`)를 `crossfamily:` 근거 안 타입 토큰으로 검사한다
222
+ # (`RESIDENCY_TOKEN_GRACE_DATE`, 소급 없음). 즉 「이 레포 자신의 커밋 이력을 감사하는 도구」
223
+ # 에서 「모든 소비자가 실제로 실행하는 배선의 일부」로 성격이 바뀌었다 — 안 실으면 shipped
224
+ # SKILL.md 가 소비자에게 없는 스크립트를 돌리라고 지시하는 꼴이 되고, 그건 이 검사기 자신이
225
+ # 막으려는 바로 그 결함(«출하 문서가 미출하 경로를 가리킴»)이다.
226
+ # ⚠️ **패턴층도 같이 실었다** — `.claude/rules/.residency-patterns.defaults`
227
+ # (일반형 패턴만, 회사 리터럴 없음; gitignored 운영자 override `.residency-patterns` 는
228
+ # 여전히 미출하). 이게 없으면 스캐너는 "defaults 패턴 파일이 없다" 로 exit 10 — 스캐너
229
+ # 자신의 **의도된** fail-closed(운영자 override 부재)보다 더 나쁜, 설치 결함성 원인으로
230
+ # 신선 설치 100% 를 막는 형태였다. `.public-surface-patterns.defaults`(바로 위 줄)와 같은
231
+ # 2층 패턴 관례를 그대로 따른다.
232
+ # 남은 사실: 운영자 override 없는 신선 설치는 여전히 exit 10 이 **기본**이다 — 이건 결함이
233
+ # 아니라 스캐너 자신의 문서화된 설계이고(§Step 4.5 가 명시), auto-decorrelation 은 그 결과를
234
+ # `residency=NOT_SCANNED(...)` 로 정직하게 적고 `DEGRADED_*` 로 내려간다 — panel(...) 을
235
+ # 조용히 못 쓰게 될 뿐 커밋을 막지는 않는다.
200
236
  # ── caller-zero-ratchet, 2026-08-22. 셋 다 **싣는 것이 틀렸다** — 「아직 안 실었다」가 아니다 ──
201
237
  # 이 게이트는 「production 스크립트에 디스패처가 있나」를 **이 레포의 러너 표면**에 대고 판정한다.
202
238
  # 그 표면의 하나가 `.github/workflows/**` 이고, **워크플로 디렉터리는 files[] 에 없다.** 그러므로
203
239
  # 소비자 트리에서 이 검사기를 돌리면 워크플로에서만 디스패치되는 스크립트들이 전부 caller 0 으로
204
240
  # 떨어지고, 그것들은 소비자의 `caller_zero_baseline.txt` 에 선언돼 있지 않으므로 **UNDECLARED →
205
241
  # exit 1**. 즉 출하하면 **모든 신선한 설치에서 100% 적색**이다 — CLAUDE.md 가 「모든 새 install 을
206
- # 막는 게이트는 엄격한 게이트가 아니라 우회 훈련기」라고 못박은 그 형태이고, 바로 위
207
- # outbound_query_guard 블록과 **같은 사유**다.
242
+ # 막는 게이트는 엄격한 게이트가 아니라 우회 훈련기」라고 못박은 그 형태다.
243
+ # ⚠️ 종전에 여기 «바로 위 outbound_query_guard 블록과 같은 사유» 라고 적혀 있었다. 2026-09-05
244
+ # 에 끊는다 — outbound 는 출하로 갔고, 그쪽 사유는 «가드를 부르는 것이 없었다»는 사실 확인으로
245
+ # 물러졌다. **이 블록의 사유는 그대로 유효하다**: 여기는 검사기가 실제로 소비자 트리에서
246
+ # 돌면서 100% 적색을 낸다(위 outbound 는 애초에 아무도 안 불렀다). 같은 결론, 다른 기전.
208
247
  # 앵커도 같이 안 나간다. 주체 부재라는 짝 규칙 말고 **자기 자신의 사유가 하나 더 있다**: 마지막
209
248
  # 레인(L27)이 `.github/workflows/caller-zero-ratchet.yml` 을 읽어 워크플로가 리졸버를 인라인으로
210
249
  # 되돌리지 않았는지 본다. 그 파일이 없는 트리에서 그 레인은 **부재를 결함으로 읽어 적색**이 된다.