@chrono-meta/fh-gate 2.15.1 → 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 +88 -1
- package/.claude-plugin/marketplace.json +8 -2
- package/AGENTS.md +27 -0
- package/CATALOG.md +17 -0
- package/CLAUDE.md +13 -3
- package/README.ja.md +51 -7
- package/README.ko.md +48 -7
- package/README.md +43 -6
- 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 +77 -10
- package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +18 -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 +35 -1
- package/knowledge/shared/harness-core/ship_readiness_gate.md +19 -2
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +489 -3
- package/package.json +48 -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 +67 -0
- 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/backtick_guard.sh +194 -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/context_continuity_score.sh +49 -7
- package/scripts/directional_diff_gate.sh +14 -2
- package/scripts/fh-gate.sh +3 -3
- package/scripts/files_manifest_shipping_check.sh +19 -0
- package/scripts/frontier_digest_autopilot.sh +4 -1
- package/scripts/gate_pathspec_check.sh +1 -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 +79 -18
- 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 +192 -0
- package/scripts/public_surface_scan_files.sh +11 -2
- package/scripts/push_zone_check.sh +78 -0
- package/scripts/residency_closure_scan.py +252 -0
- package/scripts/revert_probe.sh +250 -0
- package/scripts/selfcheck.sh +106 -3
- package/scripts/session_close_check.sh +100 -0
- package/scripts/sim_isolated_run.sh +195 -9
- package/scripts/test_action_yml_lanes.sh +97 -0
- package/scripts/test_backtick_guard_lanes.sh +115 -0
- package/scripts/test_degrade_scan_shell_probes.sh +7 -7
- package/scripts/test_fh_qp_lanes.sh +105 -0
- package/scripts/test_files_manifest_shipping_lanes.sh +5 -5
- package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
- package/scripts/test_heavy_classifier_lanes.sh +1 -1
- package/scripts/test_lane_runner_lanes.sh +59 -33
- package/scripts/test_map_postprocess_lanes.sh +143 -0
- package/scripts/test_mapped_tracks_lanes.sh +1 -1
- 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_marker_soul_check_lanes.sh +24 -0
- package/scripts/test_node_check_lanes.sh +34 -34
- package/scripts/test_outbound_query_hook_lanes.sh +433 -0
- package/scripts/test_outbound_query_lanes.sh +87 -0
- package/scripts/test_package_coverage_lanes.sh +53 -27
- package/scripts/test_pipe_verdict_guard_lanes.sh +31 -5
- package/scripts/test_precommit_pointer_index_lanes.sh +33 -0
- package/scripts/test_preprep_diagram_lanes.sh +87 -0
- package/scripts/test_preprep_drift_anchor.sh +16 -7
- package/scripts/test_preprep_drift_anchor_lanes.sh +23 -0
- 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 +57 -0
- package/scripts/test_push_zone_lanes.sh +304 -0
- package/scripts/test_residency_closure_lanes.sh +70 -0
- package/scripts/test_revert_probe_lanes.sh +146 -0
- package/scripts/test_session_close_lanes.sh +3 -5
- package/scripts/test_sim_isolated_run_lanes.sh +136 -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/utterance_landing_check.sh +2 -2
- package/scripts/validate_yaml.sh +27 -0
- package/scripts/worktree_reclaim.sh +95 -0
- package/templates/.git-hooks/pre-commit +380 -5
- package/templates/.git-hooks/pre-push +91 -0
- package/templates/RED_TEAM_REPORT.md +49 -0
- package/templates/settings.PreToolUse.snippet.json +101 -1
- package/plugins/fh-commons/README.md +0 -38
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# proposal_hook.sh — PreToolUse(Edit|Write|Bash) advisory: a verdict/guard line in scripts/**/*.sh or
|
|
3
|
+
# templates/*.sh is about to change → put ONE proposal instruction into the model's context
|
|
4
|
+
# (additionalContext): «offer the user a known-pair control + degrade_direction_scan.sh in one line».
|
|
5
|
+
#
|
|
6
|
+
# 🟥 2026-09-04 계기 교체 — 이후 F 행은 이전 F 행과 같은 계기가 아니다. The Bash-path discriminator below
|
|
7
|
+
# was replaced after the first independent grading of the live FIRE rows
|
|
8
|
+
# (tracks/_meta/RESULT_2026-09-04_identity5-armC-live-count.md §계기 결함 1·2·3): 3 of 7 rows were not edits
|
|
9
|
+
# at all, and 6 of 8 load-bearing verdict edits that day never fired. Rows written by the old rule and rows
|
|
10
|
+
# written by this one must not be pooled into one count. Whether the open falsification window is closed,
|
|
11
|
+
# restarted, or split is the governor's / operator's decision, recorded there — not here.
|
|
12
|
+
#
|
|
13
|
+
# WHY A HOOK (identity ⑤, measured 2026-09-03)
|
|
14
|
+
# r3: a CLAUDE.md table row keyed on this exact file class fired 1/15 at floor tier (that 1 a
|
|
15
|
+
# recitation). r4: this hook, installed in a disposable clone, fired on 9/10 editing reps and the
|
|
16
|
+
# floor session relayed the proposal 9/9; the hard negative (a usage-string edit in a .sh) 0/5.
|
|
17
|
+
# Same tasks, same tier, same prose layer — the row 0, the channel 100%. That is the
|
|
18
|
+
# «explicit instruction 3/3 · advisory 0/3 · framing 0/3» result of 2026-08-21 seen a second time
|
|
19
|
+
# (tracks/_meta/RESULT_2026-09-03_identity5-r4.md · prior_art_prompt.sh header). A channel is
|
|
20
|
+
# built at the channel (§Mechanization Boundary); what the proposal SAYS stays the model's.
|
|
21
|
+
#
|
|
22
|
+
# WHAT IT DOES NOT CLAIM
|
|
23
|
+
# Relaying an injected instruction is not initiative. Of r4's 9 hits, 4 carried task-specific
|
|
24
|
+
# substance beyond the hook's own wording (the K1 «judgment residue» — innovator signal
|
|
25
|
+
# fh_signal_2026-09-03_innovator-identity5-r4.md). This hook opens the window; whether ⑤'s bar
|
|
26
|
+
# («proposes unasked») counts a relayed proposal is the operator's call, recorded there, not here.
|
|
27
|
+
#
|
|
28
|
+
# DISCRIMINATOR (mechanical, quote-aware where it can be)
|
|
29
|
+
# file class : scripts/**/*.sh · templates/*.sh · */.git-hooks/* (docs, tracks, tests-as-fixtures: no)
|
|
30
|
+
# edit kind : the touched text carries a verdict/guard token — exit N · return N ·
|
|
31
|
+
# `|| continue|exit|true|return` · `&& continue|exit` · -ne/-eq/-gt/-lt · ==/!= ·
|
|
32
|
+
# `[ -e/-f/-s/-n/-z` · comm/diff/cmp · grep -q — AND for Edit the change is not
|
|
33
|
+
# confined to quoted strings (old/new with quotes stripped must differ). A usage
|
|
34
|
+
# string that happens to contain `exit 2` does not fire (r4 HARD 0/5).
|
|
35
|
+
# Edit/Write : the file is `tool_input.file_path` — the tool tells us what changes; nothing is inferred.
|
|
36
|
+
# Bash path : the shell has no file_path, so the question «which file does this command WRITE» is answered
|
|
37
|
+
# by a small scanner instead of a whole-command regex (the 2026-09-03 rule matched the raw
|
|
38
|
+
# text, so a heredoc body, a PR body, or a python string that merely QUOTED `sed -i … x.sh`
|
|
39
|
+
# counted as an edit — RESULT 2026-09-04 rows 2·3·4):
|
|
40
|
+
# ① heredoc bodies are lifted out (`<<EOF` … `EOF`) and kept aside, keyed to the line that opened them
|
|
41
|
+
# ② the rest is split into segments on `;` `&&` `||` newline — OUTSIDE quotes; quoted text is masked
|
|
42
|
+
# ③ a target is an ARGUMENT POSITION only: `> path` · `>> path` · `tee [-a] path` (unquoted in the
|
|
43
|
+
# masked text, or a quoted path directly after the operator) · the file argument of `sed -i …`
|
|
44
|
+
# ④ a `python3 … <<HEREDOC` segment is opened: `open(P,"w"|"a")` / `Path(P).write_text` with P a
|
|
45
|
+
# literal or a variable assigned ONCE in the body (`p="scripts/x.sh"`) names the target
|
|
46
|
+
# (RESULT §결함 2 — 16 of 27 script edits that day were this shape and all were silent)
|
|
47
|
+
# ⑤ the token test runs on the segment that OWNS the target (+ its heredoc body), not on the
|
|
48
|
+
# whole command — `|| exit 1` two segments later is a check, not an edit (RESULT §결함 3, row 6).
|
|
49
|
+
# For a python heredoc the token text is the body's STRING LITERALS only, so `if a == b:` in the
|
|
50
|
+
# python code itself is not a verdict edit, and a comment-word replace stays silent
|
|
51
|
+
# ⑥ a path containing `$` is dropped — `: > "$T/scripts/x.sh"` is a fixture root, not this repo
|
|
52
|
+
# Quotes are NOT stripped from the owning segment's token text: in `sed -i 's/exit 1/exit 2/' x.sh`
|
|
53
|
+
# the token sits inside the quotes by construction (Air 2026-09-03).
|
|
54
|
+
# WHAT THE BASH PATH STILL CANNOT SEE (named, not closed)
|
|
55
|
+
# · a script path reached through a variable (`"$REPO_ROOT/scripts/x.sh"`, `$f`) — ⑥ drops it
|
|
56
|
+
# · python targets built at runtime (`os.path.join`, `sys.argv[1]`, `p = base + name`)
|
|
57
|
+
# · edits by other tools: `perl -pi`, `awk … > tmp && mv tmp x.sh`, `patch`, `git apply`, `ed`,
|
|
58
|
+
# `cp`/`mv`/`install` onto a script, `sponge`
|
|
59
|
+
# · an unterminated quote earlier in the command swallows every target after it
|
|
60
|
+
# · a token inside the owning segment that is not the edit payload (`sed -i 's/a/b/' x.sh | grep -q y`
|
|
61
|
+
# is still one segment) — narrower than before, not zero
|
|
62
|
+
# It is PreToolUse, so «what actually changed on disk» is not available here at all; a PostToolUse
|
|
63
|
+
# twin diffing mtimes would close the tool-shape residuals above and was NOT built in this patch
|
|
64
|
+
# (it needs a wiring change in settings.json, outside this file).
|
|
65
|
+
#
|
|
66
|
+
# DEGRADE DIRECTION: advisory, exit 0 always, no permissionDecision (same contract as pipe_verdict_guard).
|
|
67
|
+
# Unparseable payload → silent. python3 absent → silent (a dead interpreter must not block edits).
|
|
68
|
+
# Evidence line appended to $CLAUDE_PROJECT_DIR/.claude/.proposal_hook_events.tsv (gitignored dir)
|
|
69
|
+
# so a sim arm can prove the hook fired INSIDE its clone (runner header: absence of that file
|
|
70
|
+
# invalidates the arm, never the hypothesis).
|
|
71
|
+
# Opt out on one call with `# noqa: proposal-hook`.
|
|
72
|
+
# test: printf '%s' '<PreToolUse JSON>' | bash scripts/proposal_hook.sh
|
|
73
|
+
set -u
|
|
74
|
+
RAW=$(cat 2>/dev/null || true)
|
|
75
|
+
printf '%s' "$RAW" | grep -qE '#[[:space:]]*noqa:?[[:space:]]*proposal-hook' && exit 0
|
|
76
|
+
# 🟥 bash-version trap (measured 2026-09-04): `PYCODE=$(cat <<'PY' … PY)` parses under macOS bash 3.2 but
|
|
77
|
+
# bash 5.3 (CI runner, homebrew) scans the $(...) body for a matching `)` BEFORE honoring the quoted
|
|
78
|
+
# heredoc, so the regex parens below break it — `bash -n` fails, lanes were green locally. The
|
|
79
|
+
# opposite direction of the bash-3.2 heredoc bug frontier_digest_autopilot.sh documents; neither
|
|
80
|
+
# version is safe with a heredoc inside a command substitution. A function body is parsed the same
|
|
81
|
+
# way by both, so the heredoc lives in a function and $(...) only calls it.
|
|
82
|
+
_fh_ph_pycode() { cat <<'PY'
|
|
83
|
+
import json,sys,re
|
|
84
|
+
try: d=json.load(sys.stdin)
|
|
85
|
+
except Exception: print("",""); sys.exit(0)
|
|
86
|
+
tn=d.get("tool_name",""); ti=d.get("tool_input",{}) or {}
|
|
87
|
+
TOK=r"exit [0-9]|return [0-9]|\|\| *(continue|exit|true|return)|&& *(continue|exit)|-ne |-eq |-gt |-lt | == | != |\[ -[efsnz] |\bcomm |\bdiff |\bcmp |grep -q"
|
|
88
|
+
CLASS=re.compile(r"(scripts/[^\s]*\.sh$|templates/[^\s]*\.sh$|(^|/)\.git-hooks/[^/]+$)")
|
|
89
|
+
def strip(x): return re.sub(r"\"[^\"]*\"|'[^']*'","",x)
|
|
90
|
+
def is_target(p): return bool(p) and "$" not in p and bool(CLASS.search(p))
|
|
91
|
+
def tok(x): return bool(re.search(TOK,x))
|
|
92
|
+
|
|
93
|
+
def lift_heredocs(cmd):
|
|
94
|
+
# replace each `<<[-]['"]DELIM['"]` with \x02k\x02 and cut its body out; bodies[k]=text
|
|
95
|
+
HD=re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
|
|
96
|
+
lines=cmd.split("\n"); out=[]; bodies=[]; i=0
|
|
97
|
+
while i<len(lines):
|
|
98
|
+
ln=lines[i]; i+=1; ms=list(HD.finditer(ln))
|
|
99
|
+
for m in ms:
|
|
100
|
+
k=len(bodies); body=[]
|
|
101
|
+
while i<len(lines) and lines[i].strip()!=m.group(2): body.append(lines[i]); i+=1
|
|
102
|
+
i+=1
|
|
103
|
+
bodies.append("\n".join(body)); ln=ln.replace(m.group(0),"\x02%d\x02"%k,1)
|
|
104
|
+
out.append(ln)
|
|
105
|
+
return "\n".join(out),bodies
|
|
106
|
+
|
|
107
|
+
def segments(text):
|
|
108
|
+
# split on ; && || newline outside quotes; returns [(raw, masked, quoted_list)]
|
|
109
|
+
segs=[]; raw=[]; masked=[]; quoted=[]; q=None; i=0; n=len(text)
|
|
110
|
+
def flush():
|
|
111
|
+
segs.append(("".join(raw),"".join(masked),list(quoted))); raw[:]=[]; masked[:]=[]; quoted[:]=[]
|
|
112
|
+
while i<n:
|
|
113
|
+
c=text[i]
|
|
114
|
+
if q:
|
|
115
|
+
j=i
|
|
116
|
+
while j<n:
|
|
117
|
+
if text[j]=="\\" and q=='"' and j+1<n: j+=2; continue
|
|
118
|
+
if text[j]==q: break
|
|
119
|
+
j+=1
|
|
120
|
+
body=text[i:j]; raw.append(body); masked.append("\x01%d\x01"%len(quoted)); quoted.append(body)
|
|
121
|
+
if j<n: raw.append(q); masked.append(q)
|
|
122
|
+
q=None; i=j+1; continue
|
|
123
|
+
if c in "\"'": q=c; raw.append(c); masked.append(c); i+=1; continue
|
|
124
|
+
if c=="\\" and i+1<n: raw.append(text[i:i+2]); masked.append(text[i:i+2]); i+=2; continue
|
|
125
|
+
if text.startswith("&&",i) or text.startswith("||",i): flush(); i+=2; continue
|
|
126
|
+
if c==";" or c=="\n": flush(); i+=1; continue
|
|
127
|
+
raw.append(c); masked.append(c); i+=1
|
|
128
|
+
flush(); return segs
|
|
129
|
+
|
|
130
|
+
PATH_RE=r"(?:[\"']\x01(\d+)\x01[\"']|([^\s\"'|;&<>()]+))"
|
|
131
|
+
RED=re.compile(r"(?:(?<![<\w])>>?|\btee\s+(?:-a\s+)?)\s*"+PATH_RE)
|
|
132
|
+
PYW=re.compile(r"open\(\s*(?:['\"]([^'\"]+)['\"]|([A-Za-z_]\w*))\s*,\s*['\"][wa]|Path\(\s*(?:['\"]([^'\"]+)['\"]|([A-Za-z_]\w*))\s*\)\.write_(?:text|bytes)|\b([A-Za-z_]\w*)\.write_(?:text|bytes)\(")
|
|
133
|
+
PYSTR=re.compile(r"\"\"\"(?:.|\n)*?\"\"\"|'''(?:.|\n)*?'''|\"(?:\\.|[^\"\\\n])*\"|'(?:\\.|[^'\\\n])*'")
|
|
134
|
+
|
|
135
|
+
def bash_targets(cmd):
|
|
136
|
+
text,bodies=lift_heredocs(cmd); found=[]
|
|
137
|
+
for raw,masked,quoted in segments(text):
|
|
138
|
+
hb="\n".join(bodies[int(k)] for k in re.findall(r"\x02(\d+)\x02",masked))
|
|
139
|
+
cands=[]
|
|
140
|
+
for m in RED.finditer(masked):
|
|
141
|
+
p=quoted[int(m.group(1))] if m.group(1) else m.group(2)
|
|
142
|
+
if is_target(p): cands.append((p,raw+"\n"+hb))
|
|
143
|
+
if re.search(r"\bsed\s+(?:-\S+\s+)*-i",masked):
|
|
144
|
+
for a in masked.split():
|
|
145
|
+
if is_target(a): cands.append((a,raw+"\n"+hb)); break
|
|
146
|
+
if hb and re.search(r"\bpython[0-9.]*\b",masked):
|
|
147
|
+
assigns=dict(re.findall(r"^\s*([A-Za-z_]\w*)\s*=\s*(?:Path\(\s*)?['\"]([^'\"\n]+)['\"]\s*\)?\s*$",hb,re.M))
|
|
148
|
+
lits=" ".join(PYSTR.findall(hb))
|
|
149
|
+
for m in PYW.finditer(hb):
|
|
150
|
+
p=m.group(1) or m.group(3) or assigns.get(m.group(2) or m.group(4) or m.group(5) or "","")
|
|
151
|
+
if is_target(p): cands.append((p,lits))
|
|
152
|
+
for p,ttext in cands: found.append((p,"1" if tok(ttext) else "0"))
|
|
153
|
+
for p,f in found:
|
|
154
|
+
if f=="1": return p,f
|
|
155
|
+
return (found[0] if found else ("","0"))
|
|
156
|
+
|
|
157
|
+
fp=""; flag="0"
|
|
158
|
+
if tn in ("Edit","Write"):
|
|
159
|
+
fp=ti.get("file_path","") or ""
|
|
160
|
+
old=ti.get("old_string","") or ""; new=(ti.get("new_string","") or ti.get("content","") or "")
|
|
161
|
+
touches=tok(old+"\n"+new); real=strip(old).strip()!=strip(new).strip()
|
|
162
|
+
flag="1" if (touches and real) else "0"
|
|
163
|
+
elif tn=="Bash":
|
|
164
|
+
fp,flag=bash_targets(ti.get("command","") or "")
|
|
165
|
+
print(fp, flag)
|
|
166
|
+
PY
|
|
167
|
+
}
|
|
168
|
+
PYCODE=$(_fh_ph_pycode)
|
|
169
|
+
read -r FP FLAG < <(printf '%s' "$RAW" | python3 -c "$PYCODE" 2>/dev/null) || exit 0
|
|
170
|
+
[ -n "${FP:-}" ] || exit 0
|
|
171
|
+
case "$FP" in *scripts/*.sh|*templates/*.sh|scripts/*.sh|templates/*.sh|*/.git-hooks/*|.git-hooks/*) ;; *) exit 0 ;; esac # .git-hooks/* has no .sh suffix — the gate files themselves were outside the filter (arm C wt2 2026-09-03: pre-commit edit, no FIRE)
|
|
172
|
+
[ "${FLAG:-0}" = 1 ] || exit 0
|
|
173
|
+
_D="${CLAUDE_PROJECT_DIR:-.}/.claude"; mkdir -p "$_D" 2>/dev/null
|
|
174
|
+
printf '%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "FIRE" "$FP" >> "$_D/.proposal_hook_events.tsv" 2>/dev/null
|
|
175
|
+
# ── Fact lines (r8, 2026-09-03): the two preconditions of the proposal are DETERMINISTIC, so the hook checks
|
|
176
|
+
# them itself and carries the result as a «사실» line — agents propose, solvers verify. Measured r8: on the
|
|
177
|
+
# stimulus whose grounds sit in a neighbouring file, wording-only (r7 B) got 1/5 withdraw/amend, fact lines
|
|
178
|
+
# got 4/5 (hand-judged, n=5). Sonnet used the fact as an INPUT (one rep rejected a stale fact against a
|
|
179
|
+
# reproduced bug; one opened the scan file itself) — it did not recite it.
|
|
180
|
+
# Self-lane case: editing `scripts/test_X_lanes.sh` IS the lane — r8's discriminator missed it and emitted
|
|
181
|
+
# a proposal for a lane that already was the file. Fixed here (F2 lane).
|
|
182
|
+
_ROOT="${CLAUDE_PROJECT_DIR:-.}"; _BN=$(basename "$FP" .sh); _FACT=""; _ITEMS=""
|
|
183
|
+
case "$_BN" in
|
|
184
|
+
test_*_lanes) _FACT="$_FACT · 사실: 이 파일 자체가 레인(known-pair 픽스처)이다 — 새 known-pair 컨트롤은 «이 파일 안에» 추가하거나 생략" ;;
|
|
185
|
+
*) if [ -f "$_ROOT/scripts/test_${_BN}_lanes.sh" ]; then _FACT="$_FACT · 사실: 이 파일의 레인 \`scripts/test_${_BN}_lanes.sh\` 가 이미 있다(known-pair 컨트롤은 거기에 붙이거나 생략)"; else _ITEMS="$_ITEMS known-pair(고친 케이스 + 반대 케이스) 컨트롤"; fi ;;
|
|
186
|
+
esac
|
|
187
|
+
_SCAN=$(ls -t "$_ROOT"/scripts/.degrade_scan_last_*.txt 2>/dev/null | head -1)
|
|
188
|
+
if [ -n "$_SCAN" ] && grep -q -- "$(basename "$FP")" "$_SCAN" 2>/dev/null; then _FACT="$_FACT · 사실: 오늘 degrade_direction_scan 결과 \`$(basename "$_SCAN")\` 가 이 파일을 이미 덮었다($(grep -m1 -oE 'findings: [0-9]+' "$_SCAN" 2>/dev/null || echo 'findings: ?')) — 스캐너 통과이지 손 확인이 아니다"; else _ITEMS="$_ITEMS degrade_direction_scan.sh 로 실패가 «통과»로 접히는 방향 확인"; fi
|
|
189
|
+
if [ -z "$_ITEMS" ]; then MSG="🔎 방금 고치는 줄은 판정·가드·종료코드 줄이다(scripts/*.sh · templates/*.sh)${_FACT}. 둘 다 이미 있으니 새 제안은 내지 말고, 작업을 마친 뒤 그 사실을 한 줄로만 말해라(형식: «확인 | basis: <위 사실>»)."
|
|
190
|
+
else MSG="🔎 방금 고치는 줄은 판정·가드·종료코드 줄이다(scripts/*.sh · templates/*.sh)${_FACT}. 없는 것만 사용자에게 한 줄로 제안해라 —${_ITEMS} — 형식은 «제안: … | basis: <네가 확인한 근거 한 구절>». 가능하면 이 파일·이 케이스의 실제 이름으로. 제안이지 실행이 아니다."; fi
|
|
191
|
+
python3 -c 'import json,sys; m=sys.argv[1]; print(json.dumps({"systemMessage":m,"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":m}}, ensure_ascii=False))' "$MSG" 2>/dev/null || exit 0
|
|
192
|
+
exit 0
|
|
@@ -124,7 +124,16 @@ fi
|
|
|
124
124
|
# content-generating lifecycle is ever added.
|
|
125
125
|
# ── Resolve the exact npm-published file set (fail-closed if unresolved OR partial) ──
|
|
126
126
|
FILES=$(npm pack --dry-run --json 2>/dev/null \
|
|
127
|
-
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{JSON.parse(s)[0].files.forEach(f=>console.log(f.path))}catch(e){process.exit(3)}})' 2>/dev/null
|
|
127
|
+
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{JSON.parse(s)[0].files.forEach(f=>console.log(f.path))}catch(e){process.exit(3)}})' 2>/dev/null)
|
|
128
|
+
# 2026-09-04 (v3.0.0 first OIDC publish): on the CI runner, inside `npm publish`'s prepublishOnly,
|
|
129
|
+
# `npm pack --dry-run --json` returned JSON WITHOUT files[] and this resolution came back EMPTY —
|
|
130
|
+
# fail-closed (correct) but the publish could not proceed at all. Same fallback as the other two
|
|
131
|
+
# tarball readers (package_coverage_check.sh · files_manifest_shipping_check.sh): rebuild the set
|
|
132
|
+
# from the text listing (`npm notice <size> <path>`). Still fail-closed when THAT is empty too.
|
|
133
|
+
if [ -z "$FILES" ]; then
|
|
134
|
+
FILES=$(npm pack --dry-run 2>&1 | sed -nE 's/^npm notice +[0-9.]+[kMG]?B +([^ ]+) *$/\1/p')
|
|
135
|
+
[ -n "$FILES" ] && echo " ⚠️ npm pack --json carried no files[] — file set rebuilt from the text listing ($(printf '%s\n' "$FILES" | wc -l | tr -d ' ') paths)"
|
|
136
|
+
fi
|
|
128
137
|
if [ -z "$FILES" ]; then
|
|
129
138
|
echo " ❌ could not resolve the npm-published file set (npm pack --dry-run failed)."
|
|
130
139
|
[ "${PUBLIC_SURFACE_OK:-0}" = "1" ] && { echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1"; exit 0; }
|
|
@@ -135,7 +144,7 @@ fi
|
|
|
135
144
|
# Wrong-set guard (challenger M6): a future npm --json shape change could yield a NON-empty but PARTIAL
|
|
136
145
|
# file list (forEach iterates a renamed/nested structure without throwing) → files silently unscanned.
|
|
137
146
|
# npm always ships package.json in the tarball; its absence means the parse got a wrong set → fail-closed.
|
|
138
|
-
if ! printf '%s\n' "$FILES" | grep -qx "package.json"; then
|
|
147
|
+
if ! printf '%s\n' "$FILES" | grep -qx "package.json"; then # portability-noqa: checks npm's own packaging invariant (every npm tarball ships package.json), not a repo-specific fixture read from disk — true for any ported npm package
|
|
139
148
|
echo " ❌ published file set looks wrong — 'package.json' (always shipped) is absent from the parse."
|
|
140
149
|
[ "${PUBLIC_SURFACE_OK:-0}" = "1" ] && { echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1"; exit 0; }
|
|
141
150
|
echo " Fail-closed (possible npm --json shape change). Verify npm pack output or PUBLIC_SURFACE_OK=1."
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# push_zone_check.sh — enumerate every repo you are about to push to, and say which channel each
|
|
3
|
+
# one requires. Run it BEFORE a multi-repo push round, not per-push.
|
|
4
|
+
#
|
|
5
|
+
# WHY THIS SHAPE
|
|
6
|
+
# The operator's account rule (a push under a non-owner account goes through the REST Contents API,
|
|
7
|
+
# new files only, never overwriting or deleting) is not hard to remember — it was read and correctly
|
|
8
|
+
# applied on 2026-07-26. It was applied to ONE org repo and missed on ANOTHER org repo in the same
|
|
9
|
+
# round. The failure was not ignorance of the rule; it was applying it at the point of discovery
|
|
10
|
+
# instead of across every repo in scope — the half-fix / propagation-boundary class.
|
|
11
|
+
#
|
|
12
|
+
# A per-push guard would not have caught that either: each individual push looks locally fine. What
|
|
13
|
+
# was missing is the ENUMERATION — "here are all the remotes in play tonight, and here is the
|
|
14
|
+
# channel each one needs". So this tool lists, it does not block.
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# bash scripts/push_zone_check.sh # scan sibling repos under the projects root
|
|
18
|
+
# bash scripts/push_zone_check.sh ~/p/a ~/p/b # scan the given repos
|
|
19
|
+
#
|
|
20
|
+
# Zone rule is intentionally NOT hardcoded here: the owner account and org names are
|
|
21
|
+
# operator-private. It reads them from the same gitignored source the surface scan uses, and says
|
|
22
|
+
# UNCALIBRATED if that source is missing rather than guessing (a guessed zone verdict is worse than
|
|
23
|
+
# no verdict — it would authorise the wrong channel).
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
FH="$(git -C "$(dirname "$0")/.." rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
27
|
+
ZONE_SRC="${PUSH_ZONE_OWNERS:-$FH/.claude/rules/.push-zone-owners}"
|
|
28
|
+
|
|
29
|
+
owner_accounts=""
|
|
30
|
+
if [ -f "$ZONE_SRC" ]; then
|
|
31
|
+
owner_accounts="$(grep -vE '^\s*(#|$)' "$ZONE_SRC" | tr '\n' ' ')"
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
if [ -z "$owner_accounts" ]; then
|
|
35
|
+
echo "push_zone_check: UNCALIBRATED — no owner-account list at $ZONE_SRC"
|
|
36
|
+
echo " Create it (gitignored, one account/org per line: the accounts whose repos you may push to"
|
|
37
|
+
echo " with plain git). Without it this tool cannot tell a personal remote from an org remote,"
|
|
38
|
+
echo " and a guessed verdict would authorise the wrong channel."
|
|
39
|
+
exit 2
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
active="$(gh auth status 2>/dev/null | sed -n 's/.*Logged in to github.com account \([^ ]*\).*/\1/p' | head -1)"
|
|
43
|
+
[ -n "$active" ] || active="(unknown)"
|
|
44
|
+
|
|
45
|
+
echo "push_zone_check — active gh account: $active"
|
|
46
|
+
echo "owner accounts (plain git allowed): $owner_accounts"
|
|
47
|
+
echo
|
|
48
|
+
|
|
49
|
+
targets=("$@")
|
|
50
|
+
if [ "${#targets[@]}" -eq 0 ]; then
|
|
51
|
+
root="$(dirname "$FH")"
|
|
52
|
+
while IFS= read -r d; do targets+=("$(dirname "$d")"); done < <(find "$root" -maxdepth 2 -name .git -type d 2>/dev/null | sort)
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
need_rest=0
|
|
56
|
+
for repo in "${targets[@]}"; do
|
|
57
|
+
[ -d "$repo/.git" ] || continue
|
|
58
|
+
url="$(git -C "$repo" remote get-url origin 2>/dev/null)"
|
|
59
|
+
[ -n "$url" ] || continue
|
|
60
|
+
# owner = the path segment before the repo name, for both ssh and https forms
|
|
61
|
+
owner="$(printf '%s' "$url" | sed -E 's#^.*[:/]([^/]+)/[^/]+$#\1#')"
|
|
62
|
+
ahead="$(git -C "$repo" rev-list --count '@{u}..HEAD' 2>/dev/null || echo '?')"
|
|
63
|
+
channel="REST Contents API (new files only, no overwrite/delete)"
|
|
64
|
+
mark="🔶"
|
|
65
|
+
for o in $owner_accounts; do
|
|
66
|
+
if [ "$owner" = "$o" ]; then channel="plain git push"; mark="✅"; break; fi
|
|
67
|
+
done
|
|
68
|
+
[ "$mark" = "🔶" ] && need_rest=$((need_rest + 1))
|
|
69
|
+
printf '%s %-26s owner=%-18s unpushed=%-3s → %s\n' "$mark" "$(basename "$repo")" "$owner" "$ahead" "$channel"
|
|
70
|
+
done
|
|
71
|
+
|
|
72
|
+
echo
|
|
73
|
+
if [ "$need_rest" -gt 0 ]; then
|
|
74
|
+
echo "⚠️ $need_rest repo(s) are outside the owner accounts — those need the REST channel."
|
|
75
|
+
echo " Decide the channel for ALL of them now, in one pass. The 2026-07-26 miss was exactly"
|
|
76
|
+
echo " this: the rule was applied to one such repo and forgotten on another in the same round."
|
|
77
|
+
fi
|
|
78
|
+
echo "This tool lists; it does not block. The blocking floor for THIS repo stays templates/.git-hooks/pre-push."
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Residency 폐포 스캔 — 외부 계열 dispatch 전에 **무엇이 실제로 나가는지**를 판별한다.
|
|
3
|
+
|
|
4
|
+
운영자 결정(2026-08-09, 요지 — 원문 인용 아님): 엔진 소스 자체는 보호 대상 IP 가 아니고,
|
|
5
|
+
외부 계열 송신 가부의 기준은 **폐포 스캔**이다.
|
|
6
|
+
|
|
7
|
+
→ residency 대상은 **코드 자체가 아니라 거기 섞여 들어간 조직 식별자**다.
|
|
8
|
+
그래서 판별자는 「이 레포는 누구 것인가」(사람 판단)가 아니라
|
|
9
|
+
**「이 모듈의 로컬 임포트 폐포에 조직 식별자가 있는가」**(기계 판정)다.
|
|
10
|
+
|
|
11
|
+
## 왜 폐포인가 — 그리고 폐포의 한계
|
|
12
|
+
|
|
13
|
+
파일 하나만 보면 그 파일이 import 하는 설정/클라이언트 모듈에 박힌 호스트명을 놓친다.
|
|
14
|
+
실측(2026-08-09, qasp-dev):
|
|
15
|
+
|
|
16
|
+
<clean module> 폐포 2개 조직 식별자 0건 → 밖으로 나가도 된다
|
|
17
|
+
<config-coupled> 폐포 2개 조직토큰 ×2 · 호스트 ×3 → 안 된다 (설정 모듈 경유)
|
|
18
|
+
<wide closure> 폐포 27개 조직토큰 ×30 · 호스트 ×30 → 안 된다
|
|
19
|
+
|
|
20
|
+
🟡 **폐포는 스크리닝이지 송신물 그 자체가 아니다.** 폐포가 0이면 확실히 안전하지만,
|
|
21
|
+
폐포가 0이 아니라고 **반드시 못 보내는 것은 아니다** — 오염된 파일을 payload 에서
|
|
22
|
+
빼면 된다. 이 스크립트는 «어느 파일이 오염됐는지» 를 찍어서 그 선택을 가능하게 한다.
|
|
23
|
+
(`--files` 로 실제 송신 파일 목록을 주면 폐포 대신 그것만 잰다.)
|
|
24
|
+
|
|
25
|
+
## 🟥 CLEAN 은 «보내도 안전» 이 아니다 — 스크리닝이다
|
|
26
|
+
|
|
27
|
+
cross-family 적대검증(gpt-5.5, 2026-08-09)이 false-CLEAN 경로 다수를 냈고 **픽스처로 재현**했다.
|
|
28
|
+
확정 3건은 닫았다(상대 임포트 · 루트 화이트리스트 · `from pkg import sub`; 레인 테스트 참조).
|
|
29
|
+
**닫지 못한 것은 원리적이라 이름을 붙여 남긴다** — 이걸 안 적고 CLEAN 을 floor 로 쓰면 유출한다:
|
|
30
|
+
|
|
31
|
+
· 동적 임포트(`importlib`) · 플러그인 엔트리포인트 · 네임스페이스 패키지 → 간선이 안 보인다
|
|
32
|
+
· **비-파이썬 반출물**(.env · YAML/JSON · 픽스처 · 노트북 · 락파일) → 폐포에 아예 없다
|
|
33
|
+
· 인코딩/조립(문자열 분할 · base64 · 퍼센트 · 동형문자) → 정규식이 못 본다
|
|
34
|
+
· `--files` 는 **큐레이션 우회**가 가능하다 — 목록을 좁게 주면 CLEAN 이 나온다
|
|
35
|
+
· TOCTOU — 스캔 후 송신 전에 파일이 바뀌면 판정이 낡는다
|
|
36
|
+
· 파싱 실패 모듈의 의존은 안 따라간다(그 경우 stderr 로 알린다)
|
|
37
|
+
|
|
38
|
+
∴ **판정 의미**: CLEAN = «명시된 패턴·명시된 한계 안에서 조직 식별자를 못 찾았다».
|
|
39
|
+
TAINTED 는 강한 신호(찾았다)이고, CLEAN 은 약한 신호(못 찾았다)다 — 비대칭이 본질이다.
|
|
40
|
+
`not found ≠ 0` 을 이 도구 자신에게도 적용한다.
|
|
41
|
+
|
|
42
|
+
## 종료 코드
|
|
43
|
+
|
|
44
|
+
0 CLEAN — 조직 식별자 0건. 외부 계열 dispatch 가능
|
|
45
|
+
1 TAINTED — 오염 파일 있음. 그 파일을 빼거나 경계-내 패널로
|
|
46
|
+
10 HARNESS — 패턴 파일 없음/스캔 불가. **fail-closed** (0 으로 읽지 마라)
|
|
47
|
+
|
|
48
|
+
## 패턴 (2층 — 공개 레포에 조직 리터럴을 두지 않는다)
|
|
49
|
+
|
|
50
|
+
.claude/rules/.residency-patterns.defaults 커밋됨. 일반형만(조직 리터럴 없음)
|
|
51
|
+
.claude/rules/.residency-patterns gitignored. 운영자 리터럴
|
|
52
|
+
RESIDENCY_PATTERNS=<path> 환경변수 오버라이드
|
|
53
|
+
|
|
54
|
+
한 줄 = 정규식 하나. `#` 주석·빈 줄 무시.
|
|
55
|
+
🟥 **오버라이드 파일이 없으면 exit 10 이다.** 일반형 defaults 만으로 "0건" 을 내면
|
|
56
|
+
그건 «깨끗하다»가 아니라 «조직 리터럴을 안 봤다» 이고, 그 둘을 접으면
|
|
57
|
+
not-found 를 0 으로 렌더하는 그 결함이다.
|
|
58
|
+
"""
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import argparse
|
|
62
|
+
import ast
|
|
63
|
+
import os
|
|
64
|
+
import re
|
|
65
|
+
import sys
|
|
66
|
+
from collections import deque
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
EXIT_CLEAN, EXIT_TAINTED, EXIT_HARNESS = 0, 1, 10
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _module_path(repo: Path, mod: str) -> Path | None:
|
|
74
|
+
rel = mod.replace(".", "/")
|
|
75
|
+
for cand in (repo / f"{rel}.py", repo / rel / "__init__.py"):
|
|
76
|
+
if cand.is_file():
|
|
77
|
+
return cand
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _local_imports(path: Path, mod: str) -> tuple[list[str], bool]:
|
|
82
|
+
"""(후보 모듈, 파싱실패여부). **파싱 실패를 조용히 0으로 렌더하지 않는다.**
|
|
83
|
+
|
|
84
|
+
cross-family 적대검증(2026-08-09)이 재현으로 반증한 3가지를 여기서 닫는다:
|
|
85
|
+
· 상대 임포트(`from .x import y`)를 `level == 0` 조건이 통째로 버렸다
|
|
86
|
+
· `("src","scripts",…)` 루트 화이트리스트가 다른 레이아웃을 통째로 못 봤다
|
|
87
|
+
· `from pkg import sub` 가 `pkg/__init__.py` 만 훑고 `pkg/sub.py` 를 놓쳤다
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
tree = ast.parse(path.read_text(encoding="utf-8", errors="ignore"))
|
|
91
|
+
except (SyntaxError, ValueError):
|
|
92
|
+
return [], True
|
|
93
|
+
pkg = mod.rsplit(".", 1)[0] if "." in mod else ""
|
|
94
|
+
out: list[str] = []
|
|
95
|
+
for node in ast.walk(tree):
|
|
96
|
+
if isinstance(node, ast.Import):
|
|
97
|
+
out += [a.name for a in node.names]
|
|
98
|
+
elif isinstance(node, ast.ImportFrom):
|
|
99
|
+
base = node.module or ""
|
|
100
|
+
if node.level: # 상대 임포트 — 현재 패키지 기준으로 푼다
|
|
101
|
+
parts = pkg.split(".") if pkg else []
|
|
102
|
+
up = node.level - 1
|
|
103
|
+
anchor = ".".join(parts[:len(parts) - up]) if up <= len(parts) else ""
|
|
104
|
+
base = f"{anchor}.{base}".strip(".") if base else anchor
|
|
105
|
+
if base:
|
|
106
|
+
out.append(base)
|
|
107
|
+
# `from pkg import sub` — sub 가 모듈일 수 있다
|
|
108
|
+
out += [f"{base}.{a.name}" for a in node.names]
|
|
109
|
+
return out, False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def closure(repo: Path, start: str) -> tuple[list[Path], int]:
|
|
113
|
+
"""도달 가능한 **레포 안** 모듈 파일 + 파싱실패 수.
|
|
114
|
+
|
|
115
|
+
로컬 판정은 화이트리스트가 아니라 **레포 안에 해당 파일이 실재하는가**다.
|
|
116
|
+
부모 패키지의 `__init__.py` 도 포함한다 — 거기 상수가 박혀 있으면 같이 나간다.
|
|
117
|
+
"""
|
|
118
|
+
seen: set[str] = set()
|
|
119
|
+
files: list[Path] = []
|
|
120
|
+
unparsed = 0
|
|
121
|
+
q = deque([start])
|
|
122
|
+
while q:
|
|
123
|
+
mod = q.popleft()
|
|
124
|
+
if mod in seen:
|
|
125
|
+
continue
|
|
126
|
+
seen.add(mod)
|
|
127
|
+
p = _module_path(repo, mod)
|
|
128
|
+
if p is None:
|
|
129
|
+
continue
|
|
130
|
+
files.append(p)
|
|
131
|
+
# 부모 패키지 __init__.py 도 payload 에 딸려간다
|
|
132
|
+
parts = mod.split(".")
|
|
133
|
+
for i in range(1, len(parts)):
|
|
134
|
+
init = _module_path(repo, ".".join(parts[:i]))
|
|
135
|
+
if init is not None and init not in files:
|
|
136
|
+
files.append(init)
|
|
137
|
+
deps, failed = _local_imports(p, mod)
|
|
138
|
+
unparsed += int(failed)
|
|
139
|
+
q.extend(d for d in deps if d not in seen)
|
|
140
|
+
return files, unparsed
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_patterns(repo: Path) -> tuple[list[tuple[str, re.Pattern]], str | None]:
|
|
144
|
+
"""(패턴 목록, 오류). 오버라이드가 없으면 오류를 돌려준다 — fail-closed."""
|
|
145
|
+
# ★패턴은 **이 스크립트가 사는 레포**(FH)에서 찾는다 — 스캔 대상 레포가 아니다.
|
|
146
|
+
# 대상 레포 기준으로 찾으면 남의 레포를 스캔할 때마다 defaults 부재로 fail-closed 가 되어
|
|
147
|
+
# 주 용도(다른 레포의 모듈을 재는 것)를 통째로 막는다. 실측으로 잡았다.
|
|
148
|
+
home = Path(__file__).resolve().parent.parent
|
|
149
|
+
defaults = home / ".claude/rules/.residency-patterns.defaults"
|
|
150
|
+
override = Path(os.environ.get("RESIDENCY_PATTERNS", "")) if os.environ.get(
|
|
151
|
+
"RESIDENCY_PATTERNS") else home / ".claude/rules/.residency-patterns"
|
|
152
|
+
pats: list[tuple[str, re.Pattern]] = []
|
|
153
|
+
for src in (defaults, override):
|
|
154
|
+
if not src.is_file():
|
|
155
|
+
continue
|
|
156
|
+
for line in src.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
157
|
+
line = line.strip()
|
|
158
|
+
if not line or line.startswith("#"):
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
pats.append((src.name, re.compile(line, re.I)))
|
|
162
|
+
except re.error:
|
|
163
|
+
return [], f"패턴 컴파일 실패: {src}: {line!r}"
|
|
164
|
+
override_pats = 0
|
|
165
|
+
if override.is_file():
|
|
166
|
+
for line in override.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
167
|
+
line = line.strip()
|
|
168
|
+
if line and not line.startswith("#"):
|
|
169
|
+
override_pats += 1
|
|
170
|
+
if override.is_file() and override_pats == 0:
|
|
171
|
+
return [], (
|
|
172
|
+
f"운영자 패턴 파일이 비었다(주석뿐이거나 0줄): {override}\n"
|
|
173
|
+
" «비었다» 를 «깨끗하다» 로 읽으면 안 된다 — 없는 것과 같다. fail-closed.")
|
|
174
|
+
if not defaults.is_file():
|
|
175
|
+
return [], f"defaults 패턴 파일이 없다: {defaults} — 패턴 층이 불완전하다. fail-closed."
|
|
176
|
+
if not override.is_file():
|
|
177
|
+
return pats, (
|
|
178
|
+
f"운영자 패턴 파일이 없다: {override}\n"
|
|
179
|
+
" 일반형 defaults 만으로 낸 '0건' 은 «깨끗하다» 가 아니라 «조직 리터럴을 안 봤다» 다.\n"
|
|
180
|
+
" → 파일을 만들거나 RESIDENCY_PATTERNS=<path> 를 줘라. fail-closed 로 멈춘다.")
|
|
181
|
+
if not pats:
|
|
182
|
+
return [], "패턴이 하나도 없다 (defaults·override 둘 다 비었다)"
|
|
183
|
+
return pats, None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def scan(files: list[Path], pats) -> dict[Path, list[str]]:
|
|
187
|
+
hits: dict[Path, list[str]] = {}
|
|
188
|
+
for f in files:
|
|
189
|
+
text = f.read_text(encoding="utf-8", errors="ignore")
|
|
190
|
+
for _src, rx in pats:
|
|
191
|
+
found = rx.findall(text)
|
|
192
|
+
if found:
|
|
193
|
+
hits.setdefault(f, []).append(f"{rx.pattern} ×{len(found)}")
|
|
194
|
+
return hits
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main() -> int:
|
|
198
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
199
|
+
ap.add_argument("targets", nargs="+",
|
|
200
|
+
help="모듈 경로(src.act2.precondition) 또는 --files 일 때 파일 경로")
|
|
201
|
+
ap.add_argument("--repo", default=".", help="레포 루트 (기본 cwd)")
|
|
202
|
+
ap.add_argument("--files", action="store_true",
|
|
203
|
+
help="폐포를 안 따라가고 **주어진 파일만** 잰다 — 실제 송신 payload 를 잴 때")
|
|
204
|
+
args = ap.parse_args()
|
|
205
|
+
|
|
206
|
+
repo = Path(args.repo).resolve()
|
|
207
|
+
pats, err = load_patterns(repo)
|
|
208
|
+
if err:
|
|
209
|
+
print(f"🟥 HARNESS_ERROR — {err}", file=sys.stderr)
|
|
210
|
+
return EXIT_HARNESS
|
|
211
|
+
|
|
212
|
+
if args.files:
|
|
213
|
+
files = [Path(t) if Path(t).is_absolute() else repo / t for t in args.targets]
|
|
214
|
+
missing = [f for f in files if not f.is_file()]
|
|
215
|
+
if missing:
|
|
216
|
+
print(f"🟥 HARNESS_ERROR — 파일 없음: {missing}", file=sys.stderr)
|
|
217
|
+
return EXIT_HARNESS
|
|
218
|
+
else:
|
|
219
|
+
files = []
|
|
220
|
+
unparsed_total = 0
|
|
221
|
+
for t in args.targets:
|
|
222
|
+
got, unparsed = closure(repo, t)
|
|
223
|
+
unparsed_total += unparsed
|
|
224
|
+
if not got:
|
|
225
|
+
print(f"🟥 HARNESS_ERROR — 모듈을 못 찾았다: {t} (repo={repo})", file=sys.stderr)
|
|
226
|
+
return EXIT_HARNESS
|
|
227
|
+
files += got
|
|
228
|
+
files = sorted(set(files))
|
|
229
|
+
if unparsed_total:
|
|
230
|
+
print(f"🟡 파싱 실패 모듈 {unparsed_total}개 — 그 의존은 **안 따라갔다**. CLEAN 이어도 미탐 잔여다.", file=sys.stderr)
|
|
231
|
+
|
|
232
|
+
hits = scan(files, pats)
|
|
233
|
+
scope = "파일" if args.files else "폐포"
|
|
234
|
+
print(f"{scope} {len(files)}개 · 패턴 {len(pats)}개")
|
|
235
|
+
if not hits:
|
|
236
|
+
print("✅ CLEAN(스크리닝) — 이 패턴·이 한계 안에서 조직 식별자를 못 찾았다")
|
|
237
|
+
print(" 🟡 «보내도 안전» 이 아니다. 비-파이썬 반출물·동적 임포트·인코딩·"
|
|
238
|
+
"TOCTOU 는 이 도구가 못 본다 (헤더 §CLEAN 은 스크리닝이다).")
|
|
239
|
+
return EXIT_CLEAN
|
|
240
|
+
|
|
241
|
+
print(f"🟥 TAINTED — 오염 파일 {len(hits)}개")
|
|
242
|
+
for f, why in sorted(hits.items()):
|
|
243
|
+
print(f" {f.relative_to(repo) if repo in f.parents else f}")
|
|
244
|
+
for w in why:
|
|
245
|
+
print(f" {w}")
|
|
246
|
+
print("\n→ 선택지: ⓐ 이 파일들을 payload 에서 빼고 --files 로 재검 "
|
|
247
|
+
"ⓑ 경계-내 패널로 라우팅")
|
|
248
|
+
return EXIT_TAINTED
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
sys.exit(main())
|