@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,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
|
# 되돌리지 않았는지 본다. 그 파일이 없는 트리에서 그 레인은 **부재를 결함으로 읽어 적색**이 된다.
|
|
@@ -138,14 +138,54 @@ if ! printf '%s' "$NORM" | grep -qE 'set -o pipefail|set -[a-zA-Z]*o[a-zA-Z]* pi
|
|
|
138
138
|
# Branch (b)'s optional `([^|&]*;)?` prefix keeps `| (sort; tail -5); echo $?` caught — the
|
|
139
139
|
# filter need not be the group's FIRST command, only its LAST — while the `;` requirement keeps
|
|
140
140
|
# the filter at a statement start, so `| (echo cat); rc=$?` does not match through a bare word.
|
|
141
|
+
# 🟥 «바로 다음 문장» 으로 좁힌다 (2026-09-06, 운영자 실사용 신고 + known-pair 재현).
|
|
142
|
+
# 종전 꼬리는 `[;&].*\$\?` 라 파이프 뒤 **어디든** `$?` 가 있으면 잡았다 — 그게 **다른
|
|
143
|
+
# 명령의** `$?` 여도. 한 호출에 문장이 여럿인 실사용 커맨드는 거의 항상 걸린다.
|
|
144
|
+
# 실측 4팔: ①`out=$(c); rc=$?; echo "$out"|tail` 안 터짐(정상) ②`c|tail; echo $?` 터짐
|
|
145
|
+
# (known-positive) ③표시전용 안 터짐 ④`out=$(a); rc=$?; echo "$out"|tail; b; echo $?`
|
|
146
|
+
# **터짐 = 오탐**. ④의 `$?` 는 `b` 의 것이라 안 잡는 것이 옳다.
|
|
147
|
+
# ⇒ 꼬리를 `[;&]&?[^;&|]*\$\?` 로 — 파이프라인 **직후 한 문장** 안에서만 읽는다.
|
|
148
|
+
# `&?` 는 `cmd | tail && echo $?`(진짜 결함)를 살리기 위한 것이다.
|
|
149
|
+
# 이 좁힘의 재현율 손실은 사실상 0 이다: 사이에 명령이 끼면 `$?` 는 그 명령의 것이므로
|
|
150
|
+
# 원래 **잡으면 안 되는** 자리다. 훅 자기 주석이 «100% FP 는 정작 중요한 한 건을
|
|
151
|
+
# 무시하도록 훈련시킨다» 고 적었고, 운영자 신고가 정확히 그 신호였다.
|
|
141
152
|
_F='(tail|head|cat|less|more)'
|
|
142
153
|
if printf '%s' "$NORM" \
|
|
143
|
-
| grep -qE "\|&?[[:space:]]*(${_F}([[:space:]][^|;&]*)?|[({]([^|&]*;)?[[:space:]]*${_F}([[:space:]][^|;&()}]*)?[[:space:]]*;?[[:space:]]*[)}])[[:space:]]*[;&]
|
|
154
|
+
| grep -qE "\|&?[[:space:]]*(${_F}([[:space:]][^|;&]*)?|[({]([^|&]*;)?[[:space:]]*${_F}([[:space:]][^|;&()}]*)?[[:space:]]*;?[[:space:]]*[)}])[[:space:]]*[;&]&?[^;&|]*\\\$\?"; then
|
|
144
155
|
add "R2 \$? after a display filter" \
|
|
145
156
|
"\$? holds the filter's status (tail/head/cat almost always succeed), not the command's — a FAILED check reads as 0. Capture first: \`out=\$(cmd 2>&1); rc=\$?\` then print \"\$out\" | tail."
|
|
146
157
|
fi
|
|
147
158
|
fi
|
|
148
159
|
|
|
160
|
+
# ── R3 — a line that is ONLY redirections (`2>&1` alone on the line after a heredoc). ─────────
|
|
161
|
+
# zsh runs a redirection-only line as `$NULLCMD` (= `cat` by default) — measured 2026-09-04 with
|
|
162
|
+
# `zsh -x`: `+zsh:1> cat`. That `cat` reads STDIN until EOF. In this Bash tool, stdin is `/dev/null`
|
|
163
|
+
# ONLY when the harness appends `< /dev/null`, and it does NOT append it when the top-level command
|
|
164
|
+
# already carries a stdin redirect (`<` or a `<<` heredoc) — then stdin is a pipe held open for the
|
|
165
|
+
# life of the command, and `cat` never returns. Measured 2026-09-04: 9 tasks in two sessions, all of
|
|
166
|
+
# the shape `out=$(git commit -q -F - <<'CEOF' … CEOF ⏎ 2>&1)`: every commit landed within 4 s, no
|
|
167
|
+
# push ever started, the shell sat in `$( )` with no git child, killed later (exit 144). The
|
|
168
|
+
# pre-push hook never ran — it was blamed for a hang that happened before it.
|
|
169
|
+
# Heredoc BODIES are stripped first: a one-word markdown blockquote line (`> 정본`) inside a commit
|
|
170
|
+
# message is data, not a redirection. Deterministic on the remaining lines; zero-FP by construction
|
|
171
|
+
# (a redirection-only line is never what the author meant outside a heredoc body).
|
|
172
|
+
_R3_HIT=$(printf '%s\n' "$CMD" | awk '
|
|
173
|
+
BEGIN { inhd = 0; d = "" }
|
|
174
|
+
inhd { if ($0 == d || $0 ~ ("^[[:space:]]*" d "$")) { inhd = 0 }; next }
|
|
175
|
+
{
|
|
176
|
+
if (match($0, /<<-?[[:space:]]*["'"'"']?[A-Za-z_][A-Za-z_0-9]*["'"'"']?/)) {
|
|
177
|
+
d = substr($0, RSTART, RLENGTH); sub(/^<<-?[[:space:]]*/, "", d); gsub(/["'"'"']/, "", d); inhd = 1
|
|
178
|
+
}
|
|
179
|
+
# a STATEMENT at line start made only of redirections, ended by `)`, `;`, `}`, `&&`, `||` or EOL —
|
|
180
|
+
# the measured shape is `2>&1); rc=$?`, where `)` closes the `$( )` and the last statement inside
|
|
181
|
+
# it is the bare `2>&1`.
|
|
182
|
+
if ($0 ~ /^[[:space:]]*[0-9]*(>>?|<)(&[0-9]+|[[:space:]]*[^[:space:];|&<>()]+)?([[:space:]]+[0-9]*(>>?|<)(&[0-9]+|[[:space:]]*[^[:space:];|&<>()]+)?)*[[:space:]]*([;)}]|&&|\|\||$)/) { print NR ": " $0; exit }
|
|
183
|
+
}')
|
|
184
|
+
if [ -n "$_R3_HIT" ]; then
|
|
185
|
+
add "R3 redirection-only line runs \`cat\` on stdin (line ${_R3_HIT})" \
|
|
186
|
+
"zsh executes a line that is only redirections as \$NULLCMD=cat, which reads stdin to EOF. This command carries a top-level \`<\`/\`<<\`, so the harness leaves stdin as an OPEN PIPE — that cat never returns and everything after it (the push) never starts. Put the redirection on the command line: \`out=\$(git commit -q -F - 2>&1 <<'"'"'CEOF'"'"'\`."
|
|
187
|
+
fi
|
|
188
|
+
|
|
149
189
|
[ -n "$hits" ] || exit 0
|
|
150
190
|
|
|
151
191
|
if [ "${FH_PIPE_VERDICT_BLOCK:-0}" = "1" ]; then
|