@chrono-meta/fh-gate 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +441 -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 +43 -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 +97 -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,433 @@
1
+ #!/usr/bin/env bash
2
+ # test_outbound_query_hook_lanes.sh — known pairs for scripts/outbound_query_hook.sh.
3
+ # 🟥 WRITTEN BEFORE THE HOOK. The fail-before run is recorded in
4
+ # tracks/_meta/dispatch/2026-09-05_outbound-hook/REPORT.md — a lane whose red was never
5
+ # observed is not an anchor ([[feedback_anchor_can_be_decorative]]).
6
+ #
7
+ # WHAT IS BEING GUARDED
8
+ # A session's WebSearch/WebFetch call is an OUTBOUND, IRREVERSIBLE act: once the query string
9
+ # leaves, it cannot be recalled. If the string names an internal asset, the query IS the leak
10
+ # (CLAUDE.md §Field-Harness Diagnostic: internal names never reach a log, a comment, or a paste;
11
+ # §Irreversibility Gates: an irreversible surface fails CLOSED).
12
+ # scripts/outbound_query_guard.sh has owned that lint since 2026-08-21 with ZERO callers. This
13
+ # hook is its wiring — and it is the FIRST hook in this repo that can emit `permissionDecision:
14
+ # "deny"`, so its degrade direction is the whole design, not a detail.
15
+ #
16
+ # THE TWO LAYERS (the reason this is not just "run the guard from a hook")
17
+ # The guard fail-closes when the operator's gitignored override layer is absent — correct for a
18
+ # CLI the operator invokes, and WRONG for a hook in a consumer install, where that layer is
19
+ # absent by construction. Blocking every consumer's WebSearch trains the hook OFF, which is the
20
+ # "bypass trainer" CLAUDE.md names. So the hook splits the verdict by LAYER:
21
+ # override-layer hit → deny (operator's own internal literals — the real leak class)
22
+ # defaults-layer hit → advisory (universal shapes; FP-prone, and a consumer has only this)
23
+ # no override present → advisory only + a once-per-session UNCALIBRATED notice
24
+ # The layer is decided MECHANICALLY: scan twice (full set, then defaults-only) and compare the
25
+ # hit COUNTS. A boolean would collapse "both layers hit" into "defaults hit" and silently
26
+ # downgrade a real deny ([[feedback_not_found_is_not_zero_family]]).
27
+ #
28
+ # 🟥 THE OUTPUT MUST NOT CARRY THE TOKEN. psa_scan_tagged prints `SEV leak — path: 'token'`.
29
+ # A deny reason or a log row echoing that would make the LEAK GUARD ITSELF the leak channel.
30
+ # H10 asserts the fixture token appears in NO output surface. That lane is load-bearing.
31
+ set -uo pipefail
32
+ FH="$(cd "$(dirname "$0")/.." && pwd)"
33
+ H="$FH/scripts/outbound_query_hook.sh"
34
+ PASS=0; FAIL=0
35
+ chk(){ if [ "$2" = "$3" ]; then echo " ✅ $1 → $2"; PASS=$((PASS+1));
36
+ else echo " ❌ $1 → got=$2 expect=$3"; FAIL=$((FAIL+1)); fi; }
37
+
38
+ # 🟥 subject-absent → the suite is NOT green. "skipped is not passed" — a suite that passes when
39
+ # its subject is missing is the decorative anchor this repo keeps re-finding.
40
+ if [ ! -f "$H" ]; then
41
+ echo "🟥 SUBJECT ABSENT — $H"
42
+ echo " skipped is not passed: with no hook there is nothing to anchor. exit 1."
43
+ exit 1
44
+ fi
45
+
46
+ T="$(mktemp -d -t oqh.XXXXXX)" || exit 3
47
+ trap 'rm -rf "$T"' EXIT INT TERM
48
+
49
+ # ── fixture tree: the REAL relative layout, under a temp root ─────────────────────────────────
50
+ # 🟥 Deliberately NOT via PSA_*_FILE injection for the main lanes. Injecting every path would make
51
+ # a typo in the hook's own default path (→ N/A everywhere → silent fail-open) invisible to the
52
+ # whole suite. H14 exercises the injection point separately.
53
+ mk_root(){ # $1=root $2=with_override(1/0) $3=defaults_content(optional)
54
+ rm -rf "$1"; mkdir -p "$1/.claude/rules"
55
+ printf '%s' "${3:-$(printf 'HIGH\tZZDEFAULTSTOKEN\n')}" > "$1/.claude/rules/.public-surface-patterns.defaults"
56
+ [ "$2" = 1 ] && printf 'HIGH\tZZOVERRIDETOKEN\n' > "$1/.claude/rules/.public-surface-patterns"
57
+ return 0
58
+ }
59
+
60
+ # run <root> <json> [extra env assignments...] → sets OUT / RC / ERR
61
+ run(){
62
+ local root="$1" json="$2"; shift 2
63
+ ERR="$T/err.txt"
64
+ OUT="$(printf '%s' "$json" | env CLAUDE_PROJECT_DIR="$root" "$@" bash "$H" 2>"$ERR")"; RC=$?
65
+ return 0
66
+ }
67
+ verdict(){ # classify the hook's stdout
68
+ if [ -z "${OUT:-}" ]; then echo none
69
+ elif printf '%s' "$OUT" | grep -q '"permissionDecision"[[:space:]]*:[[:space:]]*"deny"'; then echo deny
70
+ elif printf '%s' "$OUT" | grep -q 'additionalContext'; then echo advisory
71
+ else echo other; fi
72
+ }
73
+ logrow(){ [ -f "$1/.claude/.outbound_hook_events.tsv" ] && tail -1 "$1/.claude/.outbound_hook_events.tsv" || echo NOLOG; }
74
+ logcol(){ logrow "$1" | awk -F'\t' -v n="$2" '{print $n}'; }
75
+ logcount(){ [ -f "$1/.claude/.outbound_hook_events.tsv" ] && wc -l < "$1/.claude/.outbound_hook_events.tsv" | tr -d ' ' || echo 0; }
76
+
77
+ WS(){ printf '{"session_id":"%s","tool_name":"WebSearch","tool_input":{"query":"%s"}}' "${2:-sessA}" "$1"; }
78
+ WF(){ printf '{"session_id":"sessA","tool_name":"WebFetch","tool_input":{"url":"%s","prompt":"%s"}}' "$1" "$2"; }
79
+
80
+ echo "── H1 known-POSITIVE — override 층 히트는 DENY 다 ──"
81
+ R="$T/r1"; mk_root "$R" 1
82
+ run "$R" "$(WS 'how do I fix ZZOVERRIDETOKEN in prod')"
83
+ chk "H1 override 토큰 → deny" "$(verdict)" deny
84
+ chk "H1 exit 0 (JSON 이 판정을 나른다)" "$RC" 0
85
+ chk "H1 로그 verdict=deny" "$(logcol "$R" 3)" deny
86
+ chk "H1 로그 layer=override" "$(logcol "$R" 4)" override
87
+
88
+ echo "── H2 LAYER CONTROL — defaults 층만 히트하면 ADVISORY, deny 아니다 ──"
89
+ # 🟥 이것이 두 층 설계의 판별 레인이다. 여기서 deny 가 나오면 소비자 install 이 통째로 막힌다.
90
+ R="$T/r2"; mk_root "$R" 1
91
+ run "$R" "$(WS 'why does ZZDEFAULTSTOKEN appear here')"
92
+ chk "H2 defaults 토큰(override 존재) → advisory" "$(verdict)" advisory
93
+ chk "H2 exit 0" "$RC" 0
94
+ chk "H2 로그 verdict=advisory" "$(logcol "$R" 3)" advisory
95
+ chk "H2 로그 layer=defaults" "$(logcol "$R" 4)" defaults
96
+
97
+ echo "── H3 known-NEGATIVE — 평범한 질의는 아무 출력도 없다 (과차단 방지) ──"
98
+ R="$T/r3"; mk_root "$R" 1
99
+ run "$R" "$(WS 'bash printf portability posix')"
100
+ chk "H3 일반 질의 → 무음" "$(verdict)" none
101
+ chk "H3 exit 0" "$RC" 0
102
+ chk "H3 로그 verdict=clean" "$(logcol "$R" 3)" clean
103
+
104
+ echo "── H4 N/A — FH 체크아웃이 아니면 무음이고, 그 트리에 파일을 만들지 않는다 ──"
105
+ R="$T/r4"; rm -rf "$R"; mkdir -p "$R"
106
+ run "$R" "$(WS 'ZZOVERRIDETOKEN and ZZDEFAULTSTOKEN')"
107
+ chk "H4 defaults 부재 → 무음" "$(verdict)" none
108
+ chk "H4 exit 0" "$RC" 0
109
+ chk "H4 남의 트리에 .claude 를 안 만든다" "$( [ -e "$R/.claude" ] && echo created || echo untouched )" untouched
110
+
111
+ echo "── H4b N/A + 트리에 .claude 가 이미 있어도 아무것도 쓰지 않는다 (codex #7 — 사용자 수준 훅은 모든 레포에서 돈다) ──"
112
+ R="$T/r4b"; rm -rf "$R"; mkdir -p "$R/.claude"
113
+ run "$R" "$(WS 'anything')"
114
+ chk "H4b defaults 부재 + .claude 존재 → 무음" "$(verdict)" none
115
+ chk "H4b 이벤트 파일을 만들지 않는다" "$( [ -e "$R/.claude/.outbound_hook_events.tsv" ] && echo written || echo untouched )" untouched
116
+
117
+ echo "── H5 🟥 계기 불완전 = 미측정 = DENY (통과가 아니다) ──"
118
+ # 🟥 이 레인은 «판정»만이 아니라 «어느 가지가 냈는지»를 본다. 되돌림 프로브(2026-09-05)에서
119
+ # PSA_BAD_ROWS 검사를 지웠는데 47/47 이 그대로 초록이었다 — 라이브러리 자신의 같은 검사가
120
+ # rc=3 으로 잡아 «같은 deny» 를 냈기 때문이다. 판정만 보는 앵커는 그래서 장식이었다
121
+ # ([[feedback_anchor_can_be_decorative]] · [[feedback_three_reasons_a_lane_is_green]]).
122
+ # 사유 문구를 박아야 가지가 갈린다.
123
+ reason(){ printf '%s' "$OUT" | python3 -c 'import json,sys
124
+ try: print(json.load(sys.stdin).get("hookSpecificOutput",{}).get("permissionDecisionReason",""))
125
+ except Exception: print("")' 2>/dev/null; }
126
+ R="$T/r5"; mk_root "$R" 1 "$(printf 'HIGH_NO_TAB_ROW\nHIGH\tZZDEFAULTSTOKEN\n')"
127
+ run "$R" "$(WS 'a perfectly innocent question')"
128
+ chk "H5 패턴 형식오류 행 → deny" "$(verdict)" deny
129
+ chk "H5 사유가 «형식오류 행» 가지" "$(reason | /usr/bin/grep -c '형식오류 행' || true)" 1
130
+ chk "H5 로그 verdict=deny" "$(logcol "$R" 3)" deny
131
+ chk "H5 로그 layer=-(미측정)" "$(logcol "$R" 4)" -
132
+
133
+ echo "── H5b 🟥 라이브러리가 «안 보는» 가지 — defaults 가 비었는데 override 는 있다 ──"
134
+ # psa_scan_tagged 는 PSA_STREAM 이 비었는지·BAD_ROWS 가 있는지는 보지만 **PSA_DEFAULTS_OK 는
135
+ # 안 본다**. override 만 실린 스트림은 비어 있지 않으므로 라이브러리는 그대로 «스캔»하고,
136
+ # 보편 패턴층이 통째로 빠진 결과를 «깨끗»으로 렌더한다 — 호출자만 막을 수 있는 fail-open 이다.
137
+ R="$T/r5b"; mk_root "$R" 1 "$(printf '# only a comment, no rows\n')"
138
+ run "$R" "$(WS 'a perfectly innocent question')"
139
+ chk "H5b defaults 공백 + override 존재 → deny" "$(verdict)" deny
140
+ chk "H5b 사유가 «공용 패턴층» 가지" "$(reason | /usr/bin/grep -c '공용 패턴층' || true)" 1
141
+
142
+ echo '── H6 opt-out — (# noqa: outbound) 는 통과시키되 «로그를 남긴다» ──'
143
+ # 🟥 게임 가능한 채널이다. 그래서 보이지 않는 우회로가 되지 않도록 행이 남는다.
144
+ R="$T/r6"; mk_root "$R" 1
145
+ run "$R" "$(WS 'ZZOVERRIDETOKEN please # noqa: outbound')"
146
+ chk "H6 noqa → 무음 통과" "$(verdict)" none
147
+ chk "H6 exit 0" "$RC" 0
148
+ chk "H6 로그 verdict=noqa" "$(logcol "$R" 3)" noqa
149
+
150
+ echo "── H6b CONTROL — 남의 트리에서는 noqa 규율이 «발동조차» 안 한다 ──"
151
+ # 🟥 순서가 하중이다. opt-out 이 적용성보다 «앞»에 있으면, FH 가 아닌 트리에서 noqa 질의가
152
+ # 로그를 쓰려다 실패하고 → fail-closed 규칙이 **남의 레포 호출을 막는다.** 과차단이다.
153
+ R="$T/r6b"; rm -rf "$R"; mkdir -p "$R"
154
+ run "$R" "$(WS 'anything # noqa: outbound')"
155
+ chk "H6b 비-FH 트리 + noqa → 무음" "$(verdict)" none
156
+ chk "H6b exit 0" "$RC" 0
157
+ chk "H6b 그 트리에 .claude 를 안 만든다" "$( [ -e "$R/.claude" ] && echo created || echo untouched )" untouched
158
+
159
+ echo "── H7 WebFetch — url 과 prompt 를 «둘 다» 본다 (prompt 쪽 토큰 적발) ──"
160
+ R="$T/r7"; mk_root "$R" 1
161
+ run "$R" "$(WF 'https://example.com/docs' 'summarize how ZZOVERRIDETOKEN is wired')"
162
+ chk "H7 WebFetch prompt 토큰 → deny" "$(verdict)" deny
163
+ chk "H7 로그 tool=WebFetch" "$(logcol "$R" 2)" WebFetch
164
+
165
+ echo "── H7b WebFetch — url 쪽 토큰도 본다 ──"
166
+ R="$T/r7b"; mk_root "$R" 1
167
+ run "$R" "$(WF 'https://example.com/ZZOVERRIDETOKEN/x' 'summarize')"
168
+ chk "H7b WebFetch url 토큰 → deny" "$(verdict)" deny
169
+
170
+ echo "── H8 🟥 개행+탭 우회 — 토큰을 path 필드에 숨길 수 없다 ──"
171
+ R="$T/r8"; mk_root "$R" 1
172
+ run "$R" '{"session_id":"sessA","tool_name":"WebSearch","tool_input":{"query":"safe question\nZZOVERRIDETOKEN\tdecoy"}}'
173
+ chk "H8 개행+탭 뒤 토큰 → deny" "$(verdict)" deny
174
+
175
+ echo "── H9 CONTROL — 대상이 아닌 도구는 건드리지 않는다 (무음 · 로그도 없음) ──"
176
+ R="$T/r9"; mk_root "$R" 1
177
+ run "$R" '{"session_id":"sessA","tool_name":"Bash","tool_input":{"command":"echo ZZOVERRIDETOKEN"}}'
178
+ chk "H9 Bash → 무음" "$(verdict)" none
179
+ chk "H9 Bash → 로그 0행" "$(logcount "$R")" 0
180
+
181
+ echo "── H10 🟥 유출 금지 — 어떤 출력면에도 토큰 «값» 이 없다 ──"
182
+ # 이 레인이 이 훅의 존재 이유를 지킨다: 유출 가드가 유출 채널이 되면 안 된다.
183
+ R="$T/r10"; mk_root "$R" 1
184
+ run "$R" "$(WS 'leak ZZOVERRIDETOKEN and ZZDEFAULTSTOKEN together')"
185
+ ALL="$OUT
186
+ $(cat "$ERR" 2>/dev/null)
187
+ $(cat "$R/.claude/.outbound_hook_events.tsv" 2>/dev/null)"
188
+ chk "H10 stdout+stderr+로그에 override 토큰 없음" \
189
+ "$(printf '%s' "$ALL" | /usr/bin/grep -c 'ZZOVERRIDETOKEN' || true)" 0
190
+ chk "H10 stdout+stderr+로그에 defaults 토큰 없음" \
191
+ "$(printf '%s' "$ALL" | /usr/bin/grep -c 'ZZDEFAULTSTOKEN' || true)" 0
192
+ chk "H10 그래도 판정은 deny (침묵이 아니라 무해한 사유)" "$(verdict)" deny
193
+
194
+ echo "── H11 CONTROL — 스캐너 라이브러리가 없으면 DENY (미측정) ──"
195
+ R="$T/r11"; mk_root "$R" 1
196
+ run "$R" "$(WS 'harmless')" PSA_LIB_FILE="$T/does_not_exist.sh"
197
+ chk "H11 라이브러리 부재 → deny" "$(verdict)" deny
198
+
199
+ echo "── H12 소비자 install — override 층 부재: advisory 만, 그리고 UNCALIBRATED 1회 ──"
200
+ R="$T/r12"; mk_root "$R" 0
201
+ run "$R" "$(WS 'about ZZDEFAULTSTOKEN' sess1)"
202
+ chk "H12 override 부재 + defaults 히트 → advisory(deny 아님)" "$(verdict)" advisory
203
+ chk "H12 첫 호출에 UNCALIBRATED 고지" \
204
+ "$(printf '%s' "$OUT" | /usr/bin/grep -c 'UNCALIBRATED' || true)" 1
205
+ run "$R" "$(WS 'about ZZDEFAULTSTOKEN again' sess1)"
206
+ chk "H12 같은 세션 2번째 → 고지 없음(세션당 1회)" \
207
+ "$(printf '%s' "$OUT" | /usr/bin/grep -c 'UNCALIBRATED' || true)" 0
208
+ run "$R" "$(WS 'about ZZDEFAULTSTOKEN again' sess2)"
209
+ chk "H12 다른 세션 → 고지 다시" \
210
+ "$(printf '%s' "$OUT" | /usr/bin/grep -c 'UNCALIBRATED' || true)" 1
211
+ chk "H12 로그 layer=defaults" "$(logcol "$R" 4)" defaults
212
+
213
+ echo "── H12b 소비자 install + 깨끗한 질의 → 완전 무음 (고지도 안 뜬다) ──"
214
+ # 🟥 고지를 히트에만 붙인다. 모든 WebSearch 에 붙이면 그게 소음이고, 소음은 훅을 끄게 만든다.
215
+ R="$T/r12b"; mk_root "$R" 0
216
+ run "$R" "$(WS 'bash printf portability' sess9)"
217
+ chk "H12b 소비자 + 깨끗 → 무음" "$(verdict)" none
218
+ chk "H12b 로그 verdict=clean" "$(logcol "$R" 3)" clean
219
+
220
+ echo "── H13 DEGRADE — 파싱 불가 payload 는 무음 통과 (죽은 해석기가 도구를 막지 않는다) ──"
221
+ R="$T/r13"; mk_root "$R" 1
222
+ run "$R" 'this is not json at all {{{'
223
+ chk "H13 비-JSON → 무음" "$(verdict)" none
224
+ chk "H13 exit 0" "$RC" 0
225
+
226
+ echo "── H14 주입점 — PSA_DEFAULTS_FILE / PSA_OVERRIDE_FILE 가 실제로 먹는다 ──"
227
+ R="$T/r14"; rm -rf "$R"; mkdir -p "$R/.claude"
228
+ printf 'HIGH\tZZINJECTED\n' > "$T/inj_def"
229
+ printf 'HIGH\tZZINJOVR\n' > "$T/inj_ovr"
230
+ run "$R" "$(WS 'contains ZZINJOVR here')" PSA_DEFAULTS_FILE="$T/inj_def" PSA_OVERRIDE_FILE="$T/inj_ovr"
231
+ chk "H14 주입된 override 토큰 → deny" "$(verdict)" deny
232
+
233
+ echo "── H15 CONTROL — 빈 질의는 막지 않는다 ──"
234
+ R="$T/r15"; mk_root "$R" 1
235
+ run "$R" '{"session_id":"sessA","tool_name":"WebSearch","tool_input":{"query":" "}}'
236
+ chk "H15 빈 질의 → 무음" "$(verdict)" none
237
+ chk "H15 exit 0" "$RC" 0
238
+
239
+ echo "── H16 🟥 미지의 tool_input 키 형태에서도 스캔한다 (문서 미기재 = 하드코딩 금지) ──"
240
+ # 공식 hooks 문서는 WebSearch/WebFetch 의 tool_input 키 이름을 «명시하지 않는다»(2026-09-05 확인).
241
+ # 키 이름을 박으면 스키마가 바뀌는 순간 조용히 fail-OPEN 한다 — 그래서 «모든 문자열 값»을 본다.
242
+ R="$T/r16"; mk_root "$R" 1
243
+ run "$R" '{"session_id":"sessA","tool_name":"WebSearch","tool_input":{"q":"ZZOVERRIDETOKEN","opts":{"deep":["also ZZOVERRIDETOKEN"]}}}'
244
+ chk "H16 미지 키 안의 토큰 → deny" "$(verdict)" deny
245
+
246
+ echo "── H17 CONTROL — deny 사유가 «비어 있지 않다» (침묵 deny 금지) ──"
247
+ R="$T/r17"; mk_root "$R" 1
248
+ run "$R" "$(WS 'ZZOVERRIDETOKEN')"
249
+ chk "H17 permissionDecisionReason 비어있지 않음" \
250
+ "$(printf '%s' "$OUT" | python3 -c 'import json,sys;
251
+ try:
252
+ d=json.load(sys.stdin); r=d.get("hookSpecificOutput",{}).get("permissionDecisionReason","")
253
+ print("nonempty" if isinstance(r,str) and len(r.strip())>10 else "empty")
254
+ except Exception: print("unparseable")' 2>/dev/null)" nonempty
255
+ chk "H17 hookEventName 이 PreToolUse" \
256
+ "$(printf '%s' "$OUT" | python3 -c 'import json,sys;
257
+ try: print(json.load(sys.stdin).get("hookSpecificOutput",{}).get("hookEventName",""))
258
+ except Exception: print("unparseable")' 2>/dev/null)" PreToolUse
259
+
260
+ echo "── H18 🟥 렌더 못 한 DENY 는 무음 통과가 되면 안 된다 (fail-closed 2차 채널) ──"
261
+ # degrade_direction_scan S1 이 `_emit` 의 `|| true` 를 지목했고 **진짜였다**: JSON 을 못 만들면
262
+ # 출력이 비고, 이 훅에서 «출력 없음» 은 곧 ALLOW 다. OQH_PY 로 렌더러만 죽여 그 가지에 도달한다
263
+ # (파싱은 진짜 python3 로 그대로 돈다 — 변수는 하나다).
264
+ R="$T/r18"; mk_root "$R" 1
265
+ run "$R" "$(WS 'ZZOVERRIDETOKEN')" OQH_PY="$T/not_an_interpreter"
266
+ chk "H18 렌더 실패 → exit 2 (차단)" "$RC" 2
267
+ chk "H18 사유가 stderr 로 나간다" "$( [ -s "$ERR" ] && echo nonempty || echo empty )" nonempty
268
+ chk "H18 stdout 은 비어 있다(허용 아님)" "$(verdict)" none
269
+ chk "H18 🟥 그 stderr 에도 토큰 값은 없다" \
270
+ "$(/usr/bin/grep -c 'ZZOVERRIDETOKEN' "$ERR" || true)" 0
271
+
272
+ echo "── H18b CONTROL — 같은 렌더 실패라도 ADVISORY 는 막지 않는다 (잃은 것은 경고뿐) ──"
273
+ R="$T/r18b"; mk_root "$R" 1
274
+ run "$R" "$(WS 'ZZDEFAULTSTOKEN')" OQH_PY="$T/not_an_interpreter"
275
+ chk "H18b advisory 렌더 실패 → exit 0" "$RC" 0
276
+ chk "H18b 로그는 advisory 로 남는다" "$(logcol "$R" 3)" advisory
277
+
278
+ echo "── H19 🟥 기록 못 하는 opt-out 은 통과시키지 않는다 ──"
279
+ # noqa 의 정당성은 «행이 남는다» 하나뿐이다. 못 남기면 보이지 않는 우회가 된다.
280
+ R="$T/r19"; mk_root "$R" 1
281
+ chmod 500 "$R/.claude"
282
+ run "$R" "$(WS 'ZZOVERRIDETOKEN # noqa: outbound')"
283
+ chmod 700 "$R/.claude"
284
+ chk "H19 로그 불가 + noqa → deny" "$(verdict)" deny
285
+ chk "H19 사유가 «기록» 가지" "$(reason | /usr/bin/grep -c '기록 못 하는 우회' || true)" 1
286
+
287
+ echo "── H19b CONTROL — 쓸 수 있으면 같은 질의가 통과한다 (H19 가 chmod 때문만은 아니다) ──"
288
+ R="$T/r19b"; mk_root "$R" 1
289
+ run "$R" "$(WS 'ZZOVERRIDETOKEN # noqa: outbound')"
290
+ chk "H19b 로그 가능 + noqa → 무음 통과" "$(verdict)" none
291
+ chk "H19b 로그 verdict=noqa" "$(logcol "$R" 3)" noqa
292
+
293
+ echo "── H20 🟥 session_id 가 없어도 필드가 밀리지 않는다 (IFS 접힘 회귀 앵커) ──"
294
+ # `IFS=$'"'"'\t'"'"' read` 였다면 빈 session_id 에서 KEYS 가 SESS 자리로 밀려 로그 5번째 칼럼이
295
+ # 비고 판정 입력이 통째로 어긋난다 ([[feedback_ifs_read_collapses_empty_fields]]).
296
+ R="$T/r20"; mk_root "$R" 1
297
+ run "$R" '{"tool_name":"WebSearch","tool_input":{"query":"ZZOVERRIDETOKEN"}}'
298
+ chk "H20 session_id 부재 → 여전히 deny" "$(verdict)" deny
299
+ chk "H20 KEYS 칼럼이 제자리(query)" "$(logcol "$R" 5)" query
300
+ chk "H20 tool 칼럼이 제자리" "$(logcol "$R" 2)" WebSearch
301
+
302
+ echo "── H21 CONTROL — 필드가 3탭이 아니면 무음 (조립 실패를 판정으로 읽지 않는다) ──"
303
+ R="$T/r21"; mk_root "$R" 1
304
+ run "$R" '{"tool_name":"WebSearch","tool_input":{"query":"ZZOVERRIDETOKEN"}}' OQH_PY=python3
305
+ chk "H21 정상 payload 는 여전히 deny (컨트롤이 살아 있다)" "$(verdict)" deny
306
+
307
+ echo "── H22 🟥 심각도 «라벨» 도 운영자가 쓴 텍스트다 — 그대로 내보내지 않는다 ──"
308
+ # 패턴 파일 1열은 관례가 HIGH/MED/LOW 일 뿐 강제되지 않는다. 사유 문구에 그 열을 그대로 실으면
309
+ # 토큰 값은 안 나가도 «라벨» 로 내부 낱말이 새어나갈 수 있다. 평범한 ASCII 대문자 1~8자가
310
+ # 아니면 `?` 로 렌더한다. 되돌림 프로브 M13 이 이 레인 없이 초록이었다 — 그래서 생겼다.
311
+ R="$T/r22"; rm -rf "$R"; mkdir -p "$R/.claude/rules"
312
+ printf 'HIGH\tZZDEFAULTSTOKEN\n' > "$R/.claude/rules/.public-surface-patterns.defaults"
313
+ printf 'ZZ_SEC1\tZZOVERRIDETOKEN\n' > "$R/.claude/rules/.public-surface-patterns"
314
+ run "$R" "$(WS 'ZZOVERRIDETOKEN')"
315
+ chk "H22 이상한 라벨 → deny 는 그대로" "$(verdict)" deny
316
+ chk "H22 라벨이 사유에 안 실린다" "$(reason | /usr/bin/grep -c 'ZZ_SEC1' || true)" 0
317
+ chk "H22 대신 ? 로 렌더된다" "$(reason | /usr/bin/grep -c '? x1' || true)" 1
318
+
319
+ echo "── H22b CONTROL — 정상 라벨은 그대로 보인다 (H22 가 «전부 ?» 라서 통과한 게 아니다) ──"
320
+ R="$T/r22b"; mk_root "$R" 1
321
+ run "$R" "$(WS 'ZZOVERRIDETOKEN')"
322
+ chk "H22b HIGH 라벨은 사유에 그대로" "$(reason | /usr/bin/grep -c 'HIGH x1' || true)" 1
323
+
324
+ echo "── H22c 전부-글자 라벨도 enum(HIGH/MED/LOW) 밖이면 ? (codex #5 — «8자 이하 글자» 규칙으로는 내부 낱말이 샌다) ──"
325
+ R="$T/r22c"; rm -rf "$R"; mkdir -p "$R/.claude/rules"
326
+ printf 'HIGH\tZZDEFAULTSTOKEN\n' > "$R/.claude/rules/.public-surface-patterns.defaults"
327
+ printf 'ACMEKEY\tZZOVERRIDETOKEN\n' > "$R/.claude/rules/.public-surface-patterns"
328
+ run "$R" "$(WS 'ZZOVERRIDETOKEN')"
329
+ chk "H22c ACMEKEY 라벨 → deny 그대로" "$(verdict)" deny
330
+ chk "H22c 라벨이 사유에 안 실린다" "$(reason | /usr/bin/grep -c 'ACMEKEY' || true)" 0
331
+ chk "H22c ? 로 렌더" "$(reason | /usr/bin/grep -c '? x1' || true)" 1
332
+
333
+ echo "── H23 🟥 «패딩으로 우회» 금지 — 긴 질의가 훅을 죽여서 통과하면 안 된다 ──"
334
+ # 실측 2026-09-05: 필드 개수 검증을 `${_META//[!\t]/}` 로 하던 초판은 bash 3.2 에서 10,000자에
335
+ # **47.7초**가 걸렸다(라이브러리는 같은 크기를 0.15초에 처리한다 — 비용은 가드였다).
336
+ # settings 의 `"timeout": 5` 에 걸려 훅이 **죽고**, 죽은 PreToolUse 훅은 non-blocking error 라
337
+ # **도구가 그대로 실행된다** — override 도 필요 없고 로그도 안 남는 우회다. 시간 상한을 박는다.
338
+ # 🟥 The lane runs the hook under the SAME timeout the shipped snippet configures — a lane that
339
+ # allowed 15s while production kills at 5s would pass a hook that production lets through
340
+ # (codex finding 10, 2026-09-05). The value is READ from the snippet, and H23c pins it.
341
+ SNIP_TO="$(python3 -c 'import json,sys
342
+ d=json.load(open(sys.argv[1]))
343
+ for h in d["project_settings_json"]["hooks"]["PreToolUse"]:
344
+ if h.get("matcher")=="WebSearch|WebFetch": print(h["hooks"][0]["timeout"]); break' "$FH/templates/settings.PreToolUse.snippet.json" 2>/dev/null)"
345
+ chk "H23c 스니펫의 이 훅 timeout 을 읽었다(레인은 그 값으로 잰다)" "$SNIP_TO" 5
346
+ [ -n "$SNIP_TO" ] || SNIP_TO=5
347
+ LONG="$(python3 -c 'print("lorem ipsum dolor sit amet "*800)')"
348
+ mkjson(){ python3 -c 'import json,sys; print(json.dumps({"session_id":"sX","tool_name":"WebSearch","tool_input":{"query":sys.argv[1]}}))' "$1"; }
349
+ R="$T/r23"; mk_root "$R" 1
350
+ t0=$(date +%s)
351
+ OUT="$(mkjson "$LONG" | env CLAUDE_PROJECT_DIR="$R" timeout "$SNIP_TO" bash "$H" 2>"$T/err23")"; RC=$?
352
+ t1=$(date +%s)
353
+ chk "H23 21KB 깨끗한 질의 → 스니펫 timeout(${SNIP_TO}s) 안에 끝난다(죽지 않음)" "$( [ "$RC" -ne 124 ] && echo alive || echo KILLED )" alive
354
+ chk "H23 그리고 판정은 clean(무음)" "$(verdict)" none
355
+ chk "H23 로그 verdict=clean" "$(logcol "$R" 3)" clean
356
+ echo " (측정: $((t1-t0))초)"
357
+
358
+ echo "── H23b CONTROL — 길다고 스캔을 건너뛰지 않는다 (패딩 뒤의 토큰도 잡는다) ──"
359
+ R="$T/r23b"; mk_root "$R" 1
360
+ OUT="$(mkjson "$LONG ZZOVERRIDETOKEN" | env CLAUDE_PROJECT_DIR="$R" timeout "$SNIP_TO" bash "$H" 2>"$T/err23b")"; RC=$?
361
+ chk "H23b 21KB 패딩 + 토큰 → deny" "$(verdict)" deny
362
+ chk "H23b 죽어서 통과한 게 아니다" "$( [ "$RC" -ne 124 ] && echo alive || echo KILLED )" alive
363
+
364
+ echo "── H24 같은 토큰이 두 층에 다 있어도 deny — 계수 비교가 dedup 에 안 무너진다 (codex #11 을 실측으로 반증하고 고정) ──"
365
+ R="$T/r24"; rm -rf "$R"; mkdir -p "$R/.claude/rules"
366
+ printf 'HIGH\tZZSAMETOKEN\n' > "$R/.claude/rules/.public-surface-patterns.defaults"
367
+ printf 'HIGH\tZZSAMETOKEN\n' > "$R/.claude/rules/.public-surface-patterns"
368
+ run "$R" "$(WS 'hello ZZSAMETOKEN')"
369
+ chk "H24 양층 동일 토큰 → deny" "$(verdict)" deny
370
+ chk "H24 로그 layer=override" "$(logcol "$R" 4)" override
371
+
372
+ echo "── H25 tool_input 이 객체가 아니라 문자열이어도 스캔한다 (codex #1) ──"
373
+ R="$T/r25"; mk_root "$R" 1
374
+ run "$R" '{"tool_name":"WebSearch","tool_input":"ZZOVERRIDETOKEN"}'
375
+ chk "H25 문자열 tool_input 의 토큰 → deny" "$(verdict)" deny
376
+
377
+ echo "── H26 깊이 8 초과 = «못 봤다» = deny (codex #2) · CONTROL 깊이 3 은 정상 판정 ──"
378
+ R="$T/r26"; mk_root "$R" 1
379
+ run "$R" '{"tool_name":"WebSearch","tool_input":{"a":{"b":{"c":{"d":{"e":{"f":{"g":{"h":{"i":{"j":"ZZOVERRIDETOKEN"}}}}}}}}}}}'
380
+ chk "H26 깊이 10 토큰 → deny" "$(verdict)" deny
381
+ chk "H26 사유 = 추출 한계(미측정)" "$(reason | /usr/bin/grep -c '추출 한계' || true)" 1
382
+ run "$R" '{"tool_name":"WebSearch","tool_input":{"a":{"b":{"query":"ZZOVERRIDETOKEN"}}}}'
383
+ chk "H26 CONTROL 깊이 3 토큰 → deny(override 층)" "$(logcol "$R" 4)" override
384
+ run "$R" '{"tool_name":"WebSearch","tool_input":{"a":{"b":{"c":{"d":{"e":{"f":{"g":{"h":{"i":{"j":"clean"}}}}}}}}}}}'
385
+ chk "H26 깊이 10 «깨끗한» 값도 deny — 못 본 것은 깨끗한 것이 아니다" "$(verdict)" deny
386
+
387
+ echo "── H27 값 4096 개 초과 → deny (codex #3) · CONTROL 101 개는 정상 ──"
388
+ R="$T/r27"; mk_root "$R" 1
389
+ MANY="$(python3 -c 'import json; print(json.dumps({"tool_name":"WebSearch","tool_input":{"query":["x"]*4100+["ZZOVERRIDETOKEN"]}}))')"
390
+ run "$R" "$MANY"
391
+ chk "H27 4101 값, 토큰이 맨 뒤 → deny" "$(verdict)" deny
392
+ chk "H27 사유 = 추출 한계" "$(reason | /usr/bin/grep -c '추출 한계' || true)" 1
393
+ FEW="$(python3 -c 'import json; print(json.dumps({"tool_name":"WebSearch","tool_input":{"query":["x"]*100+["fine"]}}))')"
394
+ run "$R" "$FEW"
395
+ chk "H27 CONTROL 101 값 깨끗 → 무음" "$(verdict)" none
396
+
397
+ echo "── H28 키 이름도 스캔하고, 로그에는 원문 키를 안 남긴다 (codex #4) ──"
398
+ R="$T/r28"; mk_root "$R" 1
399
+ run "$R" '{"tool_name":"WebSearch","tool_input":{"ZZOVERRIDETOKEN":"benign"}}'
400
+ chk "H28 토큰이 키 자리 → deny" "$(verdict)" deny
401
+ chk "H28 로그 행에 토큰 없음" "$(logrow "$R" | /usr/bin/grep -c 'ZZOVERRIDETOKEN' || true)" 0
402
+ chk "H28 로그 5열은 other" "$(logcol "$R" 5)" other
403
+ run "$R" '{"tool_name":"WebSearch","tool_input":{"query":"fine","extra_key":"fine"}}'
404
+ chk "H28 CONTROL 아는 키는 이름 그대로 · 모르는 키는 other" "$(logcol "$R" 5)" other,query
405
+
406
+ echo "── H30 tr 없는 PATH 에서도 판정이 산다 (codex #6) — 빈 값 판정이 외부 명령에 안 기댄다 ──"
407
+ SHADOW_NOTR="$T/shadow_notr"; mkdir -p "$SHADOW_NOTR"
408
+ IFS=':' read -r -a _pdirs <<< "$PATH"
409
+ for _d in "${_pdirs[@]}"; do
410
+ [ -d "$_d" ] || continue
411
+ for _f in "$_d"/*; do
412
+ [ -x "$_f" ] && [ ! -d "$_f" ] || continue
413
+ _b="${_f##*/}"; [ "$_b" = tr ] && continue
414
+ [ -e "$SHADOW_NOTR/$_b" ] || ln -s "$_f" "$SHADOW_NOTR/$_b"
415
+ done
416
+ done
417
+ if PATH="$SHADOW_NOTR" command -v tr >/dev/null 2>&1; then
418
+ chk "H30-FIXTURE shadow PATH 에서 tr 이 숨었다" visible hidden
419
+ else
420
+ R="$T/r30"; mk_root "$R" 1
421
+ run "$R" "$(WS 'ZZOVERRIDETOKEN')" PATH="$SHADOW_NOTR"
422
+ chk "H30 tr 부재 + 토큰 → deny (clean 으로 안 접힌다)" "$(verdict)" deny
423
+ # 🟥 control shape = an EMPTY tool_input — not a whitespace query. Key names are scanned too
424
+ # (H28), so a WebSearch call with a `query` key is never an empty scan text; the emptiness path
425
+ # is reachable only when there is nothing at all to scan.
426
+ run "$R" '{"tool_name":"WebSearch","tool_input":{}}' PATH="$SHADOW_NOTR"
427
+ chk "H30 CONTROL tr 부재 + 빈 tool_input → 무음(clean, 라이브러리를 안 거친다)" "$(verdict)" none
428
+ chk "H30 CONTROL 로그 verdict=clean" "$(logcol "$R" 3)" clean
429
+ fi
430
+
431
+ echo
432
+ if [ "$FAIL" -eq 0 ]; then echo "════ outbound-query-hook lanes: $PASS passed · 0 failed ════"; exit 0
433
+ else echo "🟥 outbound-query-hook lanes: $FAIL 실패 / $((PASS+FAIL))"; exit 1; fi
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bash
2
+ # test_outbound_query_lanes.sh — outbound_query_guard.sh 의 known-pair 앵커.
3
+ # 🟥 게이트는 «막나» 만이 아니라 «정상 질의를 안 막나» 도 같이 재야 한다 — 과차단은 override 를
4
+ # 반사로 만들고, 그 순간 가드가 장식이 된다(psa_scan_lib.sh 헤더가 같은 이유로 LOW allowlist 를 갖는다).
5
+ set -uo pipefail
6
+ FH="$(cd "$(dirname "$0")/.." && pwd)"
7
+ G="$FH/scripts/outbound_query_guard.sh"
8
+ PASS=0; FAIL=0
9
+ chk(){ if [ "$2" = "$3" ]; then echo " ✅ $1 → $2"; PASS=$((PASS+1));
10
+ else echo " ❌ $1 → got=$2 expect=$3"; FAIL=$((FAIL+1)); fi; }
11
+
12
+ T="$(mktemp -d -t oqg.XXXXXX)" || exit 3
13
+ # 🟥 L7 이 실제 라이브러리를 «옮긴다». 중단·실패가 그 사이에 나면 레포에 스캐너가 사라진다
14
+ # (cross-family HIGH: "LIBBAK 은 복구에 쓰이지도 않는다"). trap 으로 무조건 되돌린다.
15
+ trap 'rm -rf "$T"' EXIT INT TERM
16
+ # 픽스처 패턴 파일 — 실제 운영자 토큰을 레인에 넣지 않는다(그 자체가 유출이다)
17
+ printf 'HIGH\tZZFIXTURETOKEN\nLOW\tzzlowtok\n' > "$T/defaults"
18
+ # override 층은 «있어야» 한다 — 부재는 이제 BLOCK 이다(L8). 내용은 픽스처 전용.
19
+ printf 'HIGH\tZZOVERRIDETOKEN\n' > "$T/override"
20
+
21
+ run(){ PSA_DEFAULTS_FILE="$1" PSA_OVERRIDE_FILE="$2" bash "$G" "$3" >/dev/null 2>&1; echo $?; }
22
+
23
+ echo "── L1 known-POSITIVE — 내부 토큰이 실린 질의는 막힌다 ──"
24
+ chk "L1 내부 토큰 → BLOCK(1)" "$(run "$T/defaults" "$T/override" 'how do I fix ZZFIXTURETOKEN here')" 1
25
+
26
+ echo "── L2 known-NEGATIVE — 일반 질의는 안 막힌다 (과차단 방지) ──"
27
+ chk "L2 일반 질의 → CLEAN(0)" "$(run "$T/defaults" "$T/override" 'harness creator skill prior art')" 0
28
+
29
+ echo "── L3 🟥 계기 불완전 = 미측정. 통과가 아니라 차단이다 ──"
30
+ # defaults 가 없으면 «유출 없음» 이 아니라 «못 쟀다». 커밋 caller 는 경고하지만 외부 질의는 비가역.
31
+ chk "L3 defaults 부재 → BLOCK(3), CLEAN 아님" "$(run "$T/nope" "$T/override" 'harmless query')" 3
32
+
33
+ echo "── L4 override 채널 — 존중되고, 로그가 남는다 ──"
34
+ # 🟥 로그도 주입점으로 뺀다(2026-09-05). 박힌 경로였을 때 이 레인은 ⓐ 매 selfcheck 마다 실제
35
+ # tracks/_meta 에 행을 쌓았고 ⓑ tracks/ 가 없는 트리(npm 설치본)에서 **`+1` 이 0 이 되어
36
+ # 빨개졌다** — 실측으로 확인했고, 그게 이 가드가 출하 목록 밖에 있던 실제 이유였다.
37
+ LOG="$T/override_log"
38
+ before=$( [ -f "$LOG" ] && wc -l < "$LOG" || echo 0 )
39
+ rc=$(PSA_DEFAULTS_FILE="$T/defaults" PSA_OVERRIDE_FILE="$T/override" OUTBOUND_QUERY_OK=1 \
40
+ OUTBOUND_QUERY_LOG="$LOG" bash "$G" 'ZZFIXTURETOKEN 강행' >/dev/null 2>&1; echo $?)
41
+ after=$( [ -f "$LOG" ] && wc -l < "$LOG" || echo 0 )
42
+ chk "L4 override → 통과(0)" "$rc" 0
43
+ chk "L4 override → 로그 +1 (보이지 않는 우회로 금지)" "$((after-before))" 1
44
+
45
+ echo "── L5 CONTROL — 자리표시자가 «뒤에 오는» 진짜 토큰을 가리지 못한다 ──"
46
+ # 🟥 초판 L5 는 같은 토큰을 두 번 넣었을 뿐이라 `head -1` 뮤턴트도 첫 매치에서 막혀 **초록이었다**
47
+ # = 장식이었다(cross-family 지목). 진짜 형태: 라이브러리 PSA_PLACEHOLDER 에 걸리는 값이 **먼저**
48
+ # 오고 진짜 토큰이 **뒤에** 와야 «모든 매치를 보는가» 가 갈린다.
49
+ chk "L5 자리표시자 먼저 + 진짜 토큰 뒤 → BLOCK(1)" \
50
+ "$(run "$T/defaults" "$T/override" 'e.g. YOUR_TOKEN_HERE then ZZFIXTURETOKEN')" 1
51
+
52
+ echo "── L6 빈 질의 — 스캔할 것이 없으면 막지 않는다 ──"
53
+ chk "L6 빈 질의 → 0" "$(run "$T/defaults" "$T/override" ' ')" 0
54
+
55
+ echo "── L7 CONTROL — 라이브러리 자체가 없으면 차단 ──"
56
+ # 🟥 실물 라이브러리를 **안 옮긴다** — 주입점으로 «없는 경로» 를 준다(레포 무결).
57
+ rc7=$(PSA_LIB_FILE="$T/does_not_exist.sh" PSA_DEFAULTS_FILE="$T/defaults" \
58
+ PSA_OVERRIDE_FILE="$T/override" bash "$G" 'anything' >/dev/null 2>&1; echo $?)
59
+ chk "L7 스캐너 라이브러리 부재 → BLOCK(3)" "$rc7" 3
60
+
61
+ echo "── L8 🟥 override 층 부재 → 미측정이므로 차단 (HIGH#1 앵커) ──"
62
+ chk "L8 override 부재 → BLOCK(3), CLEAN 아님" "$(run "$T/defaults" "$T/nope_ov" 'harmless')" 3
63
+
64
+ echo "── L9 🟥 개행+탭 우회 — 토큰을 path 필드에 숨길 수 없다 (HIGH#3 앵커) ──"
65
+ chk "L9 개행+탭 뒤의 토큰 → BLOCK(1)" \
66
+ "$(run "$T/defaults" "$T/override" "$(printf 'safe question\nZZFIXTURETOKEN\tdecoy')")" 1
67
+
68
+ echo "── L10 CONTROL — 상태값이 오염돼도 fail-open 하지 않는다 (MED#2 앵커) ──"
69
+ # `[ -ne ]` 였으면 비수치 값에서 셸 에러 후 조건 false → 통과했다. `case` 는 안 그렇다.
70
+ # psa_load 가 진입 시 상태를 리셋하므로 env 주입으론 이 분기에 **도달할 수 없다**.
71
+ # 가짜 레인을 만들지 않고, 주입점으로 «오염 상태를 남기는 라이브러리» 를 넣어 실제로 도달시킨다.
72
+ cat > "$T/badlib.sh" <<'STUB'
73
+ psa_load(){ PSA_DEFAULTS_OK=oops; PSA_BAD_ROWS=0; PSA_OVERRIDE_PRESENT=1; return 0; }
74
+ psa_require_live(){ return 0; }
75
+ # 🟥 **테스트 더블이다 — psa 회귀를 구조적으로 못 잡는다** (2026-08-21 배선 리뷰 S-2).
76
+ # 이 레인의 대상은 outbound-guard 의 «질의 평탄화·override·로깅» 이지 스캐너가 아니다.
77
+ # 단위 경계로서는 정당하지만 **«psa 전수 커버»에 세면 안 된다** — 실물이 아니라 더블을 잰다
78
+ # ([[feedback_anchor_can_be_decorative]]). 스캐너 회귀는 test_psa_singlefile_lanes.sh 가 잡는다.
79
+ psa_scan_tagged(){ cat >/dev/null; return 0; }
80
+ STUB
81
+ rc10=$(PSA_LIB_FILE="$T/badlib.sh" PSA_DEFAULTS_FILE="$T/defaults" PSA_OVERRIDE_FILE="$T/override" \
82
+ bash "$G" 'harmless' >/dev/null 2>&1; echo $?)
83
+ chk "L10 상태값 비수치 → BLOCK(3)" "$rc10" 3
84
+
85
+ echo
86
+ if [ "$FAIL" -eq 0 ]; then echo "════ outbound-query lanes: $PASS passed · 0 failed ════"; exit 0
87
+ else echo "🟥 outbound-query lanes: $FAIL 실패 / $((PASS+FAIL))"; exit 1; fi
@@ -56,6 +56,22 @@ expect "R2 tail then \$?" HIT 'bash gate.sh | tail -20; echo
56
56
  expect "R2 head then \$?" HIT 'make test | head -40; rc=$?'
57
57
  expect "R2 cat then \$?" HIT 'run.sh | cat; if [ $? -ne 0 ]; then echo bad; fi'
58
58
 
59
+ echo "-- R2 «바로 다음 문장» 좁힘 (2026-09-06, 운영자 실사용 신고) --"
60
+ # 🟥 종전 꼬리 `[;&].*\$\?` 는 파이프 뒤 **어디든** `$?` 가 있으면 잡았다 — 그게 다른
61
+ # 명령의 것이어도. 한 호출에 문장이 여럿인 실사용 커맨드는 거의 항상 걸렸고, 그건
62
+ # «100% FP 는 정작 중요한 한 건을 무시하도록 훈련시킨다» 는 이 훅 자신의 경고 그대로다.
63
+ expect "R2 좁힘: 사이에 다른 명령이 끼면 그 \$? 는 그 명령의 것" CLEAN \
64
+ 'out=$(a 2>&1); rc=$?; echo "$out" | tail -1; b; echo $?'
65
+ expect "R2 좁힘: 두 문장 뒤의 \$? 도 아니다" CLEAN \
66
+ 'pytest | tail -2; echo done; echo $?'
67
+ expect "R2 좁힘: 파이프 앞에서 잡은 rc 는 원래 정상" CLEAN \
68
+ 'out=$(pytest 2>&1); rc=$?; echo "$out" | tail -2 | head -1; echo "rc=$rc"'
69
+ # 짝 — 좁히다가 진짜 결함을 죽이면 그게 과교정이다
70
+ expect "R2 좁힘 짝: 직후 문장의 \$? 는 여전히 잡는다" HIT \
71
+ 'pytest 2>&1 | tail -2; echo "rc=$?"'
72
+ expect "R2 좁힘 짝: && 로 이어진 직후 읽기도 잡는다" HIT \
73
+ 'make test | head -5 && echo $?'
74
+
59
75
  echo "-- R2 false-positive lanes: the 7 shapes this repo actually ships --"
60
76
  # Every one of these was hand-verified on 2026-07-31 as CORRECT: the final stage IS the command
61
77
  # whose status is wanted. A detector that flags these is noise, and noise trains dismissal.
@@ -226,6 +242,16 @@ else
226
242
  printf ' ❌ %-52s rc=%s out=%s\n' "C4 ascii-codec env: JSON still delivered" "$c_rc" "$c_out"; fail=$((fail+1))
227
243
  fi
228
244
 
245
+ # ── R3 — redirection-only statement (zsh NULLCMD=cat reads an open-pipe stdin). Measured 2026-09-04:
246
+ # nine `git commit -q -F - <<'CEOF' … CEOF ⏎ 2>&1)` tasks in two sessions hung after the commit landed.
247
+ # Fixtures are the measured shape verbatim (P), the same shape with the redirect moved onto the
248
+ # command line (N1 — the one-token fix), and a one-word markdown blockquote INSIDE the heredoc body
249
+ # (N2 — data, not a redirection; must stay CLEAN or the guard fires on every quoted commit message).
250
+ expect "R3 measured shape: bare 2>&1 line closes \$( )" HIT $'out=$(git commit -q -F - <<\'CEOF\'\nfix: x\n\nbody\nCEOF\n2>&1); rc=$?; echo "commit rc=$rc"'
251
+ expect "R3 bare > file line after a heredoc" HIT $'cat <<\'EOF\'\nhello\nEOF\n> out.txt\necho done'
252
+ expect "R3 fixed: 2>&1 on the command line" CLEAN $'out=$(git commit -q -F - 2>&1 <<\'CEOF\'\nfix: x\n\nbody\nCEOF\n); rc=$?; echo "commit rc=$rc"'
253
+ expect "R3 blockquote inside heredoc body is data" CLEAN $'out=$(git commit -q -F - 2>&1 <<\'CEOF\'\nfix: x\n\n> 정본\n> see also)\nCEOF\n); rc=$?'
254
+
229
255
  echo
230
256
  if [ "$fail" -eq 0 ]; then
231
257
  echo "[pipe-verdict-guard] ✅ all $pass known pairs hold"; exit 0