@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.
- 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 +441 -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 +43 -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 +97 -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,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
|