@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.
- package/.claude/regression/probes_live.yaml +137 -0
- package/.claude/rules/.residency-patterns.defaults +7 -0
- package/.claude/rules/fh_4axis_gate.md +50 -1
- package/.claude-plugin/marketplace.json +8 -2
- package/AGENTS.md +27 -0
- package/CATALOG.md +17 -0
- package/CLAUDE.md +12 -2
- package/README.ja.md +51 -7
- package/README.ko.md +48 -7
- package/README.md +37 -5
- package/README.zh.md +45 -8
- package/docs/STANDARDS_ALIGNMENT.md +61 -0
- package/docs/USER_GUIDE.md +3 -0
- package/docs/USE_CASES.md +50 -0
- package/docs/model_tier_expectations.md +60 -0
- package/knowledge/shared/harness-core/fh_three_layer_canon.md +30 -10
- package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +8 -0
- package/knowledge/shared/harness-core/harness_incubator_doctrine.md +9 -0
- package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +139 -0
- package/knowledge/shared/harness-core/measurement-integrity-checklist.md +20 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +451 -4
- package/package.json +40 -2
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-commons/skills/preprep/README.md +4 -1
- package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
- package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
- package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
- package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
- package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
- package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
- package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
- package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
- package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
- package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
- package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
- package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
- package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
- package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
- package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
- package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
- package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/CHANGELOG.md +76 -1
- package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
- package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
- package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
- package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
- package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
- package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
- package/plugins/fh-qp/README.md +71 -0
- package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
- package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
- package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
- package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
- package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
- package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
- package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
- package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
- package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
- package/plugins/fh-qp/qp_profile.example.yaml +29 -0
- package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
- package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
- package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
- package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
- package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
- package/scripts/chamber_run.sh +14 -5
- package/scripts/com.forge-harness.live-eval.plist +84 -0
- package/scripts/compaction_probe.sh +9 -35
- package/scripts/directional_diff_gate.sh +14 -2
- package/scripts/frontier_digest_autopilot.sh +4 -1
- package/scripts/map_postprocess.py +90 -0
- package/scripts/outbound_query_guard.sh +131 -0
- package/scripts/outbound_query_hook.sh +373 -0
- package/scripts/package_coverage_check.sh +55 -16
- package/scripts/pipe_verdict_guard.sh +41 -1
- package/scripts/probe_live_eval.sh +240 -0
- package/scripts/probe_live_eval_lib.py +579 -0
- package/scripts/proposal_hook.sh +120 -17
- package/scripts/push_zone_check.sh +78 -0
- package/scripts/residency_closure_scan.py +252 -0
- package/scripts/selfcheck.sh +41 -1
- package/scripts/session_close_check.sh +100 -0
- package/scripts/sim_isolated_run.sh +98 -2
- package/scripts/test_action_yml_lanes.sh +160 -0
- package/scripts/test_fh_qp_lanes.sh +105 -0
- package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
- package/scripts/test_map_postprocess_lanes.sh +143 -0
- package/scripts/test_marker_affected_lanes.sh +93 -0
- package/scripts/test_marker_crossfamily_lanes.sh +90 -6
- package/scripts/test_marker_oracle_lanes.sh +136 -0
- package/scripts/test_outbound_query_hook_lanes.sh +433 -0
- package/scripts/test_outbound_query_lanes.sh +87 -0
- package/scripts/test_pipe_verdict_guard_lanes.sh +26 -0
- package/scripts/test_preprep_diagram_lanes.sh +87 -0
- package/scripts/test_preprep_drift_anchor.sh +3 -3
- package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
- package/scripts/test_probe_live_eval_lanes.sh +437 -0
- package/scripts/test_proposal_hook_lanes.sh +22 -1
- package/scripts/test_push_zone_lanes.sh +304 -0
- package/scripts/test_residency_closure_lanes.sh +70 -0
- package/scripts/test_sim_isolated_run_lanes.sh +119 -0
- package/scripts/test_utterance_intake_lanes.sh +414 -0
- package/scripts/test_worktree_reclaim_lanes.sh +70 -0
- package/scripts/transcript_utterances.py +222 -0
- package/scripts/utterance_intake.sh +424 -0
- package/scripts/validate_yaml.sh +27 -0
- package/scripts/worktree_reclaim.sh +95 -0
- package/templates/.git-hooks/pre-commit +353 -1
- package/templates/.git-hooks/pre-push +91 -0
- package/templates/RED_TEAM_REPORT.md +49 -0
- 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
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
#
|
|
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 로 죽고, 그건 신선 설치를 막는 형태가
|
|
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
|
-
|
|
199
|
-
|
|
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
|
# 되돌리지 않았는지 본다. 그 파일이 없는 트리에서 그 레인은 **부재를 결함으로 읽어 적색**이 된다.
|