@chrono-meta/fh-gate 1.4.99 → 2.0.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-plugin/marketplace.json +2 -2
- package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +36 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +11 -0
- package/package.json +4 -2
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/CHANGELOG.md +28 -0
- package/scripts/capability_effect_probe.sh +381 -0
- package/scripts/capability_registry_check.sh +115 -6
- package/scripts/publish_freshness_check.sh +204 -0
- package/scripts/selfcheck.sh +29 -1
- package/scripts/test_capability_entrypoint_shipping.sh +75 -5
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "fh-meta",
|
|
14
|
-
"version": "
|
|
14
|
+
"version": "2.0.0",
|
|
15
15
|
"description": "Hub meta-operations toolkit — 35 skills + 7 agents. New in 1.4.53: `fh-codex-doctor` (npm bin) — Codex adapter drift scanner; reads the documented M1/M2/M3 skill tier map + skill/agent source and reports codex-native/adapter-required/claude-native/unclassified per unit, wired into `npm test`/`prepublishOnly` (fail-closed on unclassified Claude-native primitives). New in 1.4.49: steel-quench gains Step 0.6 Verdict-Invariance Probe (groundedness axis — a load-bearing judged gate's verdict must track behavior, not rubric phrasing; measured flip-count over cross-family paraphrases; arXiv:2605.06161 Policy Invariance anchor); multi_model_sidecar_strategy §Vendor-native harness (a model is strongest in its own vendor CLI — Claude/CC, GPT/codex, Gemini/Antigravity; a universal router degrades all of them, so it stays an autocomplete/QA sidecar, never orchestration); predelete_check.sh fail-closed rewrite; memory-hygiene A-TMA anchor. New in 1.4.48: phantom-quench + steel-quench gain external frontier anchors (arXiv:2607.02052 package-hallucination; arXiv:2607.02057 prompt-coverage-adequacy); README model-flat claim reframed from a per-release point-curve to structural invariants (operation flattens across tiers; depth tier-order fixed within a generation). New in 1.4.47: onboarding step ① surfaces the Mode D companion-store session-start load in the auto-read salience anchor (previously only in the local binding + rules, so a greeting could skip the load). New in 1.4.46: context-doctor command-output axis (route to rtk/proxy for verbose CLI stdout, complementing .claudeignore; risk-gated to token-scarce envs). New in 1.4.41: context-doctor 2026 trigger vocab (context engineering/rot/collapse) + phantom-citation hardening; hub measurement-integrity-checklist (cross-model measurement pre-flight: display-name pin/reps≥3/discriminating probe). New in 1.4.40: install-wizard queryable-wiki scaffold (INDEX + session-start read + R/W/C ingest). New in 1.4.39: auto-decorrelation (cross-family verifier sidecar recruitment) + video-ingest (capability-routed video ingestion). New in 1.4.x: verify-axis check-class taxonomy (mandatory-pass/measured/judged), no-reinvention Tier-0 inventory, 7-class failure taxonomy, Destructive-Op Gate, Wave-T (Temper), tier-floor governance, Mode D Model Notice, FC consent lane, default-Sonnet guidance. New in 1.3.0: public-surface-audit, field-harvest Mode B auto-trigger, 4-axis gate scope ext. Validated cross-CLI: Claude Code, Codex, Gemini.",
|
|
16
16
|
"source": "./plugins/fh-meta"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"name": "fh-commons",
|
|
20
|
-
"version": "
|
|
20
|
+
"version": "2.0.0",
|
|
21
21
|
"description": "Project-agnostic utility skills — 5 skills (convergence-loop · deliberation · mcp-circuit-breaker · token-budget-gate · ko-tech-writer) + 1 agent (quench-challenger). Domain-independent utilities transplantable into any project.",
|
|
22
22
|
"source": "./plugins/fh-commons"
|
|
23
23
|
}
|
|
@@ -354,3 +354,39 @@ the retrospective standpoint review of this section itself) is enough to mechani
|
|
|
354
354
|
not enough to claim the standpoint axis's uplift is calibrated in the same statistical sense family
|
|
355
355
|
diversity is. Record accordingly: `standpoint:` entries accumulate toward that bar, they do not
|
|
356
356
|
presuppose it already cleared.
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
### §7-b Standpoint **acquisition** — where the third party comes from (operator decision, 2026-08-15)
|
|
361
|
+
|
|
362
|
+
§7 above says *whose ground truth the check ran against*. It never said **where that standpoint
|
|
363
|
+
comes from when you don't have one**. In practice the roster was whatever repos happened to be on
|
|
364
|
+
disk, which silently caps the axis at "harnesses we already cloned".
|
|
365
|
+
|
|
366
|
+
**Operator decision (2026-08-15)**: when a change is judged **large-and-irreversible**, propose
|
|
367
|
+
cluster mode (skip the prompt for an operator who already granted standing consent) → use a local
|
|
368
|
+
repo if one fits → **if none does, search (GitHub etc.), clone, and run the standpoint review
|
|
369
|
+
against the clone** → produce the result. The clone step is covered by the same standing setup
|
|
370
|
+
consent as dispatch; it is not a separate ask.
|
|
371
|
+
|
|
372
|
+
**Selection criterion — not any repo is a standpoint.** The 2026-08-15 run measured this: the
|
|
373
|
+
third-party review that paid was against a repo carrying its **own written discipline**
|
|
374
|
+
(`CLAUDE.md` + `ETHOS.md` with explicit anti-patterns and a "search before building" rule). A repo
|
|
375
|
+
with code but no articulated discipline yields a code dump, not a standpoint — there is nothing to
|
|
376
|
+
judge *from*. Require an articulated canon (rules/ethos/contributing with normative statements)
|
|
377
|
+
before spending a clone on it.
|
|
378
|
+
|
|
379
|
+
🟥 **Read, never execute.** A standpoint review **reads** the target's canon and code. Cloning an
|
|
380
|
+
unfamiliar repo and *running* it is a different risk class and is not part of this protocol —
|
|
381
|
+
the 2026-08-15 runs (mate, gstack) were read-only, and that is the shape that generalizes.
|
|
382
|
+
Residency applies unchanged: nothing from a company-origin surface is sent outward to obtain a
|
|
383
|
+
standpoint.
|
|
384
|
+
|
|
385
|
+
**What the acquisition step does NOT settle** (named, not deferred):
|
|
386
|
+
- **Uplift is unmeasured for acquired standpoints.** The 2026-08-15 evidence used repos already on
|
|
387
|
+
disk. Whether a *newly cloned* repo yields comparable findings is **untested** — the selection
|
|
388
|
+
criterion above is reasoned from one case, not calibrated.
|
|
389
|
+
- **Cost is real and the yield is thin.** Blind external classification of that run put the
|
|
390
|
+
third-party axis's *exclusive* yield at **2 of 15** findings. Both were boundary-crossing
|
|
391
|
+
(a rule the other project had already abandoned; another repo importing the changed file) —
|
|
392
|
+
which is why it earns its place on large-and-irreversible surfaces and nowhere else.
|
|
@@ -2007,3 +2007,14 @@
|
|
|
2007
2007
|
notes: "★**Self-caught 0 of 7 A-grade.** Two different reviewer engines produced DISJOINT A-grade sets — codex found the verdict-predicate family, the challenger found the unanchored-guard family — which is the concrete argument for two legs over one leg twice, on the same diff, same day.
|
|
2008
2008
|
★**The challenger had no Bash** and said so plainly rather than implying execution ('아래는 전부 정적 추적이고, 실행 확인은 0건'). Its decisive finding was still correct, and the governor executed the revert to confirm it — sidecar finding as evidence candidate, verdict closed by a mechanical anchor.
|
|
2009
2009
|
🟥 **Self-inflicted, self-caught**: the Explore sweep was dispatched and README.md was then edited while it ran — `[[feedback_audit_target_must_be_frozen]]` reproduced. The agent itself flagged the file changing under it (494 → 572 lines). Its verdicts on the edited region were discarded; only the counts/links/translation-drift findings were used, and the translation port was deferred until the English file is committed and frozen."
|
|
2010
|
+
|
|
2011
|
+
- date: 2026-08-16
|
|
2012
|
+
session: fh-parallel-59b59c27 (Opus 5 1M, bg — v1.4.99 재출하 축, 08-15 밤에서 날짜 넘김)
|
|
2013
|
+
agents_summary: "6 dispatches: general-purpose×1 (Pre-Publish 코드 보안 패스, 출하 델타의 실행 표면) · codex gpt-5.5×3 (cross-family R2/R3/R4 — 각각 직전 수리를 과녁으로, R4 는 앞 라운드가 닫은 것을 명시해 재보고를 막음) · fh-meta:persona-innovator×1 (운영자 지시 자율주행, Mode F) · fh-meta:beginner×1 (README 새 절 콜드리드, 08-15 분)."
|
|
2014
|
+
dispatch_count: 6
|
|
2015
|
+
outcome: accepted
|
|
2016
|
+
evidence: "보안 패스가 **BLOCK** 을 냈고 그게 옳았다 — `fh_node_check.sh` 의 consent 게이트가 파일 전체 verdict + raw grep 을 클래스 조인 대신 써서, `revoked` 로 적힌 클래스가 소비자 머신에서 자동 fast-forward 를 통과했다(거버너가 컨트롤 동반 재현 후 수리). R2 는 그 수리에서 4건을 냈고 **전부 새 게이트의 ON 스위치가 조용히 꺼지는 형태**(공백만 이름·argv[1] 한정 파싱·미지 옵션이 경로로·요약이 판정과 모순). R3 는 2건, 그중 A급은 `tr -d` 가 트림이 아니라 squeeze 라 **다른 이름이 실제 클래스의 판정을 받는** 신원 붕괴 — 게이트가 막으려던 그 일을 게이트가 하고 있었다. R4 는 **새 S/A 0**(수렴), 남은 B 1건은 근거와 함께 소스에 명시 잔여. innovator 는 7건이 **하나의 관계**(proxy 가 target 보다 약함)임을 짚고 **그 이름이 사흘 전 메모리에 이미 있다**(`feedback_instrument_vs_target_and_budget`)는 것, 그리고 외부에서 가져올 것은 도구가 아니라 `partially rotten` 이라는 **상태값**이라는 것을 냈다."
|
|
2017
|
+
notes: "★**자력 적발 0 / 7건.** 검출기가 호출부 0을 10/10으로 셈 · 새 가드에 앵커 없음 · consent 게이트가 클래스를 안 봄 · 그 레인이 두 번 장식 · ON 스위치 4건 · 「바운드」가 아무것도 안 묶음 · 프로브가 실행조차 안 됐는데 초록. 리뷰어·컨트롤·되돌림·CI 가 잡았다.
|
|
2018
|
+
★**라운드를 거듭할수록 과녁을 직전 수리로 옮긴 것이 값을 냈다** — R2·R3 의 A급은 원래 결함이 아니라 **그 결함을 고치려고 내가 쓴 코드**에 있었다. R4 에 「앞 라운드가 닫은 것」을 명시해 준 것도 효과가 있었다(재보고 0, 새 축에서만 1건).
|
|
2019
|
+
⚠️ **innovator 는 자기 근거의 한계를 스스로 붙였고 그걸 지우지 않았다** — 7건을 재검증하지 못했고(편집 금지+Bash 없음) task card 진술을 그대로 받았다고 명시. 제안 1의 수율은 미측정이라고 못박았고, 손검증 불가한 EMSE 수치는 **인용을 거부**했다. 사이드카가 자기 표본 한계를 먼저 선언한 사례로 남긴다.
|
|
2020
|
+
🟥 **거버너 쪽 실책 2건**: ⓐ staleness 감사기를 띄워 놓고 그 사이 대상 파일을 편집(`feedback_audit_target_must_be_frozen` 재생산, 자력 적발) ⓑ 되돌림 프로브의 적용확인 단언이 인용부호에서 깨져 **프로브가 실행되지 않았는데 스위트는 초록**이었다 — 단언이 없었으면 「되돌려도 초록」을 「앵커 살아있음」으로 오독했을 것이다."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrono-meta/fh-gate",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "FH runtime adapters — run FH governance, skills, and agents via Claude or Codex with machine-parseable gates.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"prepare": "chmod +x bin/fh-gate.js bin/fh-run.js bin/fh-goal.js bin/fh-codex-doctor.js scripts/fh-gate.sh scripts/fh-run.sh scripts/fh-goal.sh",
|
|
28
28
|
"postinstall": "node scripts/postinstall_notice.js",
|
|
29
29
|
"test": "bash scripts/selfcheck.sh",
|
|
30
|
-
"prepublishOnly": "bash scripts/version_lockstep_check.sh && bash scripts/selfcheck.sh && bash scripts/package_coverage_check.sh --vs-tarball && bash scripts/public_surface_scan_files.sh",
|
|
30
|
+
"prepublishOnly": "bash scripts/publish_freshness_check.sh && bash scripts/version_lockstep_check.sh && bash scripts/selfcheck.sh && bash scripts/package_coverage_check.sh --vs-tarball && bash scripts/public_surface_scan_files.sh",
|
|
31
31
|
"release": "bash scripts/public_surface_scan_files.sh && npm publish"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"scripts/test_lane_runner_lanes.sh",
|
|
73
73
|
"scripts/test_version_lockstep_lanes.sh",
|
|
74
74
|
"scripts/package_coverage_check.sh",
|
|
75
|
+
"scripts/publish_freshness_check.sh",
|
|
75
76
|
"scripts/lane_runner_check.sh",
|
|
76
77
|
"scripts/test_package_coverage_lanes.sh",
|
|
77
78
|
"scripts/test_fh_gate_regressions.sh",
|
|
@@ -92,6 +93,7 @@
|
|
|
92
93
|
"templates/.claude/rules/session.md",
|
|
93
94
|
"scripts/below_floor_scan.sh",
|
|
94
95
|
"scripts/capability_registry_check.sh",
|
|
96
|
+
"scripts/capability_effect_probe.sh",
|
|
95
97
|
"scripts/chamber_run.sh",
|
|
96
98
|
"scripts/fh_env_delta_scan.sh",
|
|
97
99
|
"scripts/substrate_jump_detector.sh",
|
|
@@ -10,6 +10,34 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
|
10
10
|
|
|
11
11
|
## Plugin Level
|
|
12
12
|
|
|
13
|
+
### [2.0.0] — 2026-08-16
|
|
14
|
+
|
|
15
|
+
**BREAKING — M6 로 인해 종전 통과하던 capability 등록이 거부될 수 있다. 그리고 그 M6 는 1.4.99 에서 출하되지 않았다.**
|
|
16
|
+
|
|
17
|
+
- 🔴 **BREAKING: `capability_registry_check.sh` 에 M6(선언 진위 관측)가 실제로 도달한다.**
|
|
18
|
+
M1–M5 를 전부 통과하던 capfile 이 `writes:` 선언과 실제 부작용이 다르면 이제 **REJECTED** 된다.
|
|
19
|
+
major 인 이유는 이것 하나다 — 소비자의 초록이 빨강이 될 수 있다. `^1.4.x` 범위가 이 변경을 자동
|
|
20
|
+
수용하지 않도록 minor 가 아니라 major 로 낸다. 🟥 **M6 를 opt-in 으로 낮추는 선택지는 거부했다**:
|
|
21
|
+
비가역 표면(등록 → 이후 자동 호출)의 fail-closed 축을 끌 수 있게 만들면 그건 floor 가 아니다.
|
|
22
|
+
- 🔴 **fix: `scripts/capability_effect_probe.sh` 가 `files[]` 에 없어 출하되지 않았다.**
|
|
23
|
+
M6 를 배선한 커밋(`a48e644`)이 그 M6 가 호출하는 프로브를 출하 목록에 넣지 않았다. 실측 결과
|
|
24
|
+
1.4.99 tarball 의 `capability_registry_check.sh` 에는 **M6 가 0회** 등장한다 — 소비자는 깨진 M6 가
|
|
25
|
+
아니라 **M6 자체를 못 받았다**(출하본이 main 보다 뒤처져 있었다). 고치지 않고 재출하했다면 그때
|
|
26
|
+
프로브 부재 → fail-closed → **모든 등록 거부**가 됐다.
|
|
27
|
+
- **feat: 형제 의존 출하 검사** — `test_capability_entrypoint_shipping.sh` 가 세 번째 방향을 얻었다.
|
|
28
|
+
출하되는 셸 스크립트가 `${0%/*}/x.sh` 형태로 부르는 **형제 파일**도 `files[]` 에 있어야 한다.
|
|
29
|
+
기존 두 방향은 `*_capability.sh` **진입점 규약**에만 걸려 있어 이번 결함에 구조적으로 눈이 멀었고,
|
|
30
|
+
`package_coverage_check.sh` 는 참조 정규식이 `scripts/` 로 **시작하는 리터럴**만 봐서 런타임에
|
|
31
|
+
조립되는 경로를 볼 수 없다. 이 클래스의 4번째 발생이라 앞단에 기계로 세웠다.
|
|
32
|
+
- **fix: script-dir 해석이 symlink 를 통과한다** — `${0%/*}` 는 심링크 경유 호출에서 링크의
|
|
33
|
+
디렉토리를 가리켜 M6 가 «프로브 부재» 로 fail-closed 했다(있는데 없다고 말한다). `BASH_SOURCE` +
|
|
34
|
+
symlink 해소로 교체.
|
|
35
|
+
- **feat: publish 신선도 게이트** — `prepublishOnly` 가 «출하 트리가 커밋된 main 인가» 를 본다.
|
|
36
|
+
이번 사고와 2026-08-13 의 «남의 미커밋 초안 출하» 가 같은 표면의 두 발생이다.
|
|
37
|
+
- **fix: `sync-to-be.sh` 락 경합 경로가 rc=64 로 죽었다** — `log()` 가 호출부보다 아래에 정의돼
|
|
38
|
+
macOS `/usr/bin/log` 로 떨어졌고, `set -euo pipefail` 이 의도한 `exit 0` 도착 전에 스크립트를
|
|
39
|
+
죽였다. 경합 경로는 **경합할 때만** 실행되므로 병렬 세션 둘이 붙기 전까지 출하된 채로 있었다.
|
|
40
|
+
|
|
13
41
|
### [1.4.99] — 2026-08-15
|
|
14
42
|
|
|
15
43
|
**fix: 검출기가 «호출부 0» 을 «10/10 초록» 으로 보고하고 있었다 — 그리고 이 릴리스는 #388 의 첫 출하이기도 하다**
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# capability_effect_probe.sh — 선언된 `writes:` 가 **참인지** 관측한다 (정체성 ① 블로커 (d)).
|
|
3
|
+
#
|
|
4
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
# 왜 지어졌나 — 등록 바가 «형식» 만 재고 «진위» 를 안 잰다
|
|
6
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
# `capability_registry_check.sh` 헤더가 직접 적어놨다:
|
|
8
|
+
#
|
|
9
|
+
# "🟥 `writes:` 축은 검증 불가 — 그리고 그게 이 파일에서 실제로 터졌다.
|
|
10
|
+
# 2026-08-11, 이 검사기를 통과한 capability(`writes: read-only` 선언)의 진입점이
|
|
11
|
+
# 정리 트랩 결함으로 레포의 `scripts/` 를 rm -rf 했다. M1–M5 를 전부 통과한 채로.
|
|
12
|
+
# 구조 처방(미구축): 샌드박스에서 M4 를 돌려 쓰기 시도를 관측하는 것."
|
|
13
|
+
#
|
|
14
|
+
# 이 파일이 그 구조 처방이다. `ship_readiness_gate.md` 정체성 ① 의 블로커 (d) —
|
|
15
|
+
# *"초록이 파괴 행위에 선행할 수 있는 검사기는 초록 정체성이 아니다"* — 를 겨눈다.
|
|
16
|
+
#
|
|
17
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
# 이 프로브가 **증명하지 않는** 것 (과잉주장 금지)
|
|
19
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
# · **한 번의 실행만 본다.** 조건부로 쓰는 capability(특정 입력에서만 쓴다)는 그 입력이
|
|
21
|
+
# M4 쌍에 없으면 안 걸린다. 이건 «이 실행에서 안 썼다» 이지 «절대 안 쓴다» 가 아니다.
|
|
22
|
+
# · **샌드박스 밖은 못 본다.** 절대경로로 다른 데를 쓰면 이 프로브는 놓친다.
|
|
23
|
+
# ⟹ 그래서 **카나리아를 샌드박스 밖에도 심는다**(아래 OUTSIDE_CANARY). 그게 지워지면
|
|
24
|
+
# «샌드박스 밖 쓰기» 로 잡힌다 — 완전하진 않지만 조용하진 않다.
|
|
25
|
+
# · **네트워크·프로세스 부작용은 안 본다.** 파일시스템 축 전용이다.
|
|
26
|
+
# · 🟥 **사본은 `HEAD` 기준이다**(`git archive HEAD`). 후보 파일이 워킹트리에만 있고 커밋 전이면,
|
|
27
|
+
# 프로브가 재는 트리는 **후보 상태가 아니다.** 등록 시점에 커밋된 상태를 재는 것이 정상
|
|
28
|
+
# 경로이나, 개발 중 호출하면 이 어긋남이 조용히 생긴다 — 그래서 여기 적는다.
|
|
29
|
+
# · 🟥 **사본에 `.git` 이 없다 — 이건 의도이고 대가가 있다.** 2026-08-16 이전에는 `git worktree`
|
|
30
|
+
# 를 썼는데, 워크트리는 실물 레포의 `.git` 을 **공유**하므로 `read-only` 를 선언한 진입점이
|
|
31
|
+
# `git config --local core.pager` 로 **그 레포에 영속 코드실행을 심고도 VERIFIED** 를 받았다
|
|
32
|
+
# (보안 패스 [S], 손으로 재현). 지금은 `.git` 이 아예 없다. 대가: **git 을 실제로 쓰는
|
|
33
|
+
# capability 는 여기서 못 돈다 → UNVERIFIABLE.** 미측정을 통과로 렌더하는 것보다 낫다.
|
|
34
|
+
# · 🟥 **`.git` 외의 메타데이터 채널은 여전히 안 본다.** 이 수리는 «프로브가 스스로 열어 준
|
|
35
|
+
# 통로» 하나를 닫은 것이지 «부작용 채널 전부» 를 닫은 게 아니다.
|
|
36
|
+
# · 🟥 **절대경로 감시는 «홈 엔트리 목록» 수준이다.** 홈 **하위 디렉토리 내부**의 변경
|
|
37
|
+
# (예: `$HOME/Documents/x`)이나 `/tmp` 의 남의 파일 수정은 여전히 못 본다.
|
|
38
|
+
# 전수 스냅샷은 비용이 크고 홈이 늘 시끄러워 오탐이 난다 — 그래서 **엔트리 목록**만 본다.
|
|
39
|
+
# 이건 «가장 흔한 표적을 막는다» 이지 «절대경로 쓰기를 막는다» 가 아니다.
|
|
40
|
+
# 2026-08-16 자기 공격으로 이 층을 찾았고, 그 위 층은 여전히 열려 있다고 적어 둔다.
|
|
41
|
+
# · 실행 불가는 **PASS 가 아니다** — `UNVERIFIABLE`(3) 로 낸다. 미측정을 0 으로 렌더하지 않는다.
|
|
42
|
+
#
|
|
43
|
+
# 사용법
|
|
44
|
+
# capability_effect_probe.sh <capfile> [<capfile> ...]
|
|
45
|
+
# capability_effect_probe.sh --self-test
|
|
46
|
+
#
|
|
47
|
+
# exit code
|
|
48
|
+
# 0 VERIFIED 선언과 관측이 일치
|
|
49
|
+
# 1 VIOLATION 선언보다 **넓게** 썼다 (read-only 선언인데 썼다 등)
|
|
50
|
+
# 3 UNVERIFIABLE 돌릴 수 없었다 — 판정 아님(미측정)
|
|
51
|
+
# 10 HARNESS_ERROR capfile 파손·전제 파손
|
|
52
|
+
set -o pipefail
|
|
53
|
+
set -f
|
|
54
|
+
|
|
55
|
+
RC_OK=0; RC_VIOLATION=1; RC_UNVERIFIABLE=3; RC_HARNESS=10
|
|
56
|
+
|
|
57
|
+
# writes 축 순서 (좁음 → 넓음). 관측이 선언보다 넓으면 VIOLATION.
|
|
58
|
+
ORDER_writes="read-only write-local write-remote"
|
|
59
|
+
|
|
60
|
+
_idx() { local v="$1" i=0 x; for x in $ORDER_writes; do [ "$x" = "$v" ] && { printf '%d' "$i"; return; }; i=$((i+1)); done; printf '%d' -1; }
|
|
61
|
+
|
|
62
|
+
_key() { # $1=capfile $2=key
|
|
63
|
+
sed -n "s/^[[:space:]]*$2:[[:space:]]*//p" "$1" 2>/dev/null | head -1 \
|
|
64
|
+
| sed 's/[[:space:]]*$//'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_snapshot() { # $1=dir → "path<TAB>sha" 목록 (정렬)
|
|
68
|
+
find "$1" -type f 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
|
|
69
|
+
printf '%s\t%s\n' "$f" "$(shasum -a 256 "$f" 2>/dev/null | cut -d' ' -f1)"
|
|
70
|
+
done
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# ★정리 트랩 — 신호/중단에도 temp 가 안 남게 한다(cross-family 지목).
|
|
74
|
+
# 2026-08-16 이전에는 여기에 `git worktree remove` + `worktree prune` 분기가 있었다.
|
|
75
|
+
# 샌드박스가 워크트리가 아니게 되면서(§격리) **둘 다 지웠다**. `prune` 은 특히 지워야 했다 —
|
|
76
|
+
# 그건 **그 레포의 모든** stale 워크트리 등록을 청소하므로, 병렬 세션이 워크트리 규율을 쓰는
|
|
77
|
+
# 레포에서 **남의 등록을 건드릴 수 있었다**(보안 패스 [B]).
|
|
78
|
+
_PROBE_WT_PARENT=""; _PROBE_SANDBOX=""
|
|
79
|
+
_cleanup_probe() {
|
|
80
|
+
[ -n "$_PROBE_SANDBOX" ] && rm -rf "$_PROBE_SANDBOX"
|
|
81
|
+
[ -n "$_PROBE_WT_PARENT" ] && rm -rf "$_PROBE_WT_PARENT"
|
|
82
|
+
_PROBE_WT_PARENT=""; _PROBE_SANDBOX=""
|
|
83
|
+
}
|
|
84
|
+
trap _cleanup_probe EXIT INT TERM
|
|
85
|
+
|
|
86
|
+
probe_one() { # $1=capfile → rc
|
|
87
|
+
local cap="$1"
|
|
88
|
+
[ -r "$cap" ] || { printf '❌ capfile 도달 불가: %s\n' "$cap" >&2; return "$RC_HARNESS"; }
|
|
89
|
+
|
|
90
|
+
local declared entry pos_args neg_args
|
|
91
|
+
declared=$(_key "$cap" writes)
|
|
92
|
+
entry=$(_key "$cap" entry)
|
|
93
|
+
pos_args=$(_key "$cap" calibration_positive_args)
|
|
94
|
+
neg_args=$(_key "$cap" calibration_negative_args)
|
|
95
|
+
|
|
96
|
+
[ -n "$declared" ] || { printf '❌ %s: writes 축 선언 없음 — 검증 대상이 아니다\n' "$cap" >&2; return "$RC_HARNESS"; }
|
|
97
|
+
[ "$(_idx "$declared")" -ge 0 ] || { printf '❌ %s: writes 값이 enum 밖: %s\n' "$cap" "$declared" >&2; return "$RC_HARNESS"; }
|
|
98
|
+
[ -n "$entry" ] || { printf '❌ %s: entry 없음\n' "$cap" >&2; return "$RC_HARNESS"; }
|
|
99
|
+
|
|
100
|
+
# `requires_cwd` 가 있으면 그 레포의 **`.git` 없는 파일 사본**을 샌드박스로 쓴다.
|
|
101
|
+
# 실물 트리에서 돌리면 «파괴를 관측하려다 파괴하는» 꼴이고, 빈 임시디렉토리에서
|
|
102
|
+
# 돌리면 진입점이 전제를 못 찾아 UNVERIFIABLE 로만 끝난다(실측: 실물 capfile 10 중 4).
|
|
103
|
+
#
|
|
104
|
+
# 🟥 **리셋 먼저, 편집 나중.** 사본을 만든 뒤에 픽스처를 얹는다. 순서를 뒤집으면
|
|
105
|
+
# 사본 생성이 tracked 편집을 되돌려 프로브가 조용히 공허해진다
|
|
106
|
+
# (2026-08-16 병렬 세션 실측: 같은 순서 문제로 한 레인이 두 번 장식이 됐다).
|
|
107
|
+
local req_cwd wt=""
|
|
108
|
+
req_cwd=$(_key "$cap" requires_cwd)
|
|
109
|
+
|
|
110
|
+
local sandbox outside wt_parent=""
|
|
111
|
+
if [ -n "$req_cwd" ] && [ -d "$req_cwd/.git" ]; then
|
|
112
|
+
# 🟥 **`git worktree` 를 쓰지 않는다 — 워크트리는 정의상 실물 레포의 `.git` 을 공유한다.**
|
|
113
|
+
# 2026-08-16 보안 패스가 지목하고 거버너가 손으로 재현했다: `writes: read-only` 를 선언한
|
|
114
|
+
# 진입점이 워크트리 안에서 `git config --local core.pager "sh -c <cmd>"` 를 심으면
|
|
115
|
+
# · 그 config 는 **실물 레포**에 쓰인다(공유 `.git`)
|
|
116
|
+
# · `_snapshot` 은 샌드박스 **디렉토리 트리**만 해싱하므로 `.git/` 은 구조적으로 안 보인다
|
|
117
|
+
# · `$outside` 카나리아도 `$HOME` 감시도 이 채널을 못 본다
|
|
118
|
+
# → 프로브는 `✅ VERIFIED … 관측=no-write` 를 찍고, 그 레포의 다음 `git log` 가 공격자
|
|
119
|
+
# 명령을 실행한다. **M6 가 «위반 없음» 이라고 적는 바로 그 실행이 위반을 저지른다.**
|
|
120
|
+
# 이건 헤더의 명명된 잔여(«절대경로로 다른 데를 쓰면 놓친다»)와 **다르다**: 그건 capability 가
|
|
121
|
+
# 고른 경로이고, 이건 **프로브가 스스로 열어 준 통로**다.
|
|
122
|
+
#
|
|
123
|
+
# 처방: `git archive` 로 **`.git` 이 없는 순수 파일 사본**을 만든다. 진입점은 여전히 전제
|
|
124
|
+
# (레포의 파일 배치)를 찾고, 공유 메타데이터 채널은 존재하지 않는다.
|
|
125
|
+
# ⚠️ 대가를 명시한다: 사본에 `.git` 이 없으므로 **git 명령을 실제로 쓰는 capability 는
|
|
126
|
+
# 여기서 못 돈다** → UNVERIFIABLE 로 떨어진다. 미측정을 통과로 렌더하는 것보다 낫다.
|
|
127
|
+
wt_parent=$(mktemp -d) || return "$RC_HARNESS"
|
|
128
|
+
_PROBE_WT_PARENT="$wt_parent" # ★등록을 mktemp **직후**에 한다 — 아래 실패 경로에서
|
|
129
|
+
wt="$wt_parent/copy"; mkdir -p "$wt" # return 하면 트랩이 이걸 못 지우던 누수가 있었다
|
|
130
|
+
if ! ( cd "$req_cwd" && git archive --format=tar HEAD ) 2>/dev/null | ( cd "$wt" && tar xf - ) 2>/dev/null; then
|
|
131
|
+
printf '⚠️ UNVERIFIABLE %s — `.git` 없는 사본을 못 만들었다(%s)\n' "$cap" "$req_cwd" >&2
|
|
132
|
+
return "$RC_UNVERIFIABLE"
|
|
133
|
+
fi
|
|
134
|
+
if [ -e "$wt/.git" ]; then
|
|
135
|
+
printf '❌ %s: 사본에 .git 이 있다 — 격리 전제 파손. 판정하지 않는다\n' "$cap" >&2
|
|
136
|
+
return "$RC_HARNESS"
|
|
137
|
+
fi
|
|
138
|
+
sandbox="$wt"
|
|
139
|
+
_PROBE_SANDBOX="$wt"
|
|
140
|
+
else
|
|
141
|
+
sandbox=$(mktemp -d) || return "$RC_HARNESS"
|
|
142
|
+
_PROBE_SANDBOX="$sandbox"
|
|
143
|
+
fi
|
|
144
|
+
outside=$(mktemp -d) || return "$RC_HARNESS"
|
|
145
|
+
printf 'canary-inside\n' > "$sandbox/CANARY_INSIDE"
|
|
146
|
+
printf 'canary-outside\n' > "$outside/CANARY_OUTSIDE"
|
|
147
|
+
|
|
148
|
+
# 🟥 **홈 디렉토리 카나리아** — 2026-08-16 실측: 초판은 임시 카나리아 하나만 봐서
|
|
149
|
+
# `echo x > $HOME/...` 이 **VERIFIED 로 통과**했고 홈에 파일이 실제로 생겼다.
|
|
150
|
+
# (d) 가 말하는 결함을 프로브 자신이 갖고 있었던 것이다. 절대경로 쓰기의 가장 흔한
|
|
151
|
+
# 표적이 홈이므로 여기에도 감시를 둔다. ⚠️ 완전하지 않다 — 아래 §잔여 참조.
|
|
152
|
+
local home_before home_after
|
|
153
|
+
home_before=$(ls -A "$HOME" 2>/dev/null | LC_ALL=C sort | shasum -a 256 | cut -d" " -f1)
|
|
154
|
+
mkdir -p "$sandbox/scripts"; printf 'x\n' > "$sandbox/scripts/CANARY_SCRIPTS"
|
|
155
|
+
|
|
156
|
+
local before after out rc
|
|
157
|
+
before=$(_snapshot "$sandbox")
|
|
158
|
+
local out_before; out_before=$(_snapshot "$outside")
|
|
159
|
+
|
|
160
|
+
# 실행 — **양 arm 을 같은 샌드박스 정책으로** 돌린다(2026-08-16 cross-family 지목).
|
|
161
|
+
# 초판은 positive 만 돌렸는데, 등록 검사기 주석은 «M4 를 관측한다» 고 말했다 —
|
|
162
|
+
# 구현과 주장이 어긋났고, **negative arm 에서만 쓰는 capability 가 통과**했다.
|
|
163
|
+
# 실패는 VIOLATION 이 아니다 — 못 잰 것이다.
|
|
164
|
+
#
|
|
165
|
+
# 🟥 **args 를 검문한다** (2026-08-16 보안 패스 [A], 거버너 재현). `eval "$entry $pos_args"`
|
|
166
|
+
# 는 `entry` 가 M1 을 통과하는 순수 argv 여도 **args 하나로 탈출한다**:
|
|
167
|
+
# `calibration_positive_args: x; touch /tmp/PWNED` 가
|
|
168
|
+
# 샌드박스 밖 `/tmp` 에 쓰고도 `✅ VERIFIED · 관측=no-write` 를 받았다.
|
|
169
|
+
# 등록기(`capability_registry_check.sh`)에는 `_validate_arm_args` 가 이미 있었고
|
|
170
|
+
# **프로브에만 없었다** — 그런데 헤더 usage 가 standalone 호출을 광고하고 files[] 로 단독
|
|
171
|
+
# 출하되므로, 등록기 경유 M4 검문에 의존할 수 없다. 형제가 가진 가드를 이쪽에도 세운다.
|
|
172
|
+
_reject_arm_args() { # $1=args $2=arm 이름 → 0=통과 1=거부
|
|
173
|
+
case "$1" in
|
|
174
|
+
*'|'*|*';'*|*'&'*|*'>'*|*'<'*|*'`'*|*'$('*|*$'\n'*)
|
|
175
|
+
printf '🟥 VIOLATION %s — %s arm args 에 셸 메타문자(인젝션 표면): %s\n' "$cap" "$2" "$1"; return 1 ;;
|
|
176
|
+
*'../'*)
|
|
177
|
+
printf '🟥 VIOLATION %s — %s arm args 가 상위 경로로 탈출한다: %s\n' "$cap" "$2" "$1"; return 1 ;;
|
|
178
|
+
esac
|
|
179
|
+
return 0
|
|
180
|
+
}
|
|
181
|
+
_reject_arm_args "$pos_args" positive || return "$RC_VIOLATION"
|
|
182
|
+
_reject_arm_args "$neg_args" negative || return "$RC_VIOLATION"
|
|
183
|
+
|
|
184
|
+
# ⚠️ `eval` 은 **남긴다**, 그리고 그 이유가 측정으로 갈렸다. 처음엔 지적의 처방대로
|
|
185
|
+
# `set -- $entry $args; "$@"` 로 바꿨는데, 그러면 **셸 형태 진입점의 의미가 깨진다**:
|
|
186
|
+
# `bash -c 'git tag X; …'` 가 따옴표를 잃고 조각나 bash 가 `unexpected EOF` 로 rc=2 를 내고,
|
|
187
|
+
# 아무것도 안 바뀌었으니 프로브가 **`✅ VERIFIED`** 를 찍었다(실측). 즉 처방을 그대로
|
|
188
|
+
# 적용하면 [A] 를 닫으면서 «실행 실패를 무위반으로 렌더» 하는 더 조용한 결함이 생긴다.
|
|
189
|
+
# 이 프로브의 자기 self-test 조차 `entry: bash -c '…'` 를 쓴다.
|
|
190
|
+
# **닫아야 할 것은 args 였다** — 위 `_reject_arm_args` 가 그걸 닫는다. entry 자체는 이
|
|
191
|
+
# 도구가 «실행해서 관측하겠다» 고 선언한 대상이라 실행이 목적이지 결함이 아니다.
|
|
192
|
+
local out2 rc2
|
|
193
|
+
out=$(cd "$sandbox" && eval "$entry $pos_args" 2>&1); rc=$?
|
|
194
|
+
out2=""; rc2=0
|
|
195
|
+
if [ -n "$neg_args" ]; then
|
|
196
|
+
out2=$(cd "$sandbox" && eval "$entry $neg_args" 2>&1); rc2=$?
|
|
197
|
+
out="$out
|
|
198
|
+
$out2"
|
|
199
|
+
[ "$rc2" -gt "$rc" ] && rc="$rc2"
|
|
200
|
+
fi
|
|
201
|
+
|
|
202
|
+
after=$(_snapshot "$sandbox")
|
|
203
|
+
local out_after; out_after=$(_snapshot "$outside")
|
|
204
|
+
|
|
205
|
+
local changed outside_changed
|
|
206
|
+
changed=$([ "$before" = "$after" ] && echo 0 || echo 1)
|
|
207
|
+
outside_changed=$([ "$out_before" = "$out_after" ] && echo 0 || echo 1)
|
|
208
|
+
|
|
209
|
+
_cleanup_probe
|
|
210
|
+
rm -rf "$outside"
|
|
211
|
+
|
|
212
|
+
home_after=$(ls -A "$HOME" 2>/dev/null | LC_ALL=C sort | shasum -a 256 | cut -d" " -f1)
|
|
213
|
+
local home_changed
|
|
214
|
+
home_changed=$([ "$home_before" = "$home_after" ] && echo 0 || echo 1)
|
|
215
|
+
|
|
216
|
+
if [ "$home_changed" = "1" ]; then
|
|
217
|
+
printf '🟥 VIOLATION %s — **홈 디렉토리 엔트리가 바뀌었다**(절대경로 쓰기). 선언=%s\n' "$cap" "$declared"
|
|
218
|
+
return "$RC_VIOLATION"
|
|
219
|
+
fi
|
|
220
|
+
|
|
221
|
+
if [ "$outside_changed" = "1" ]; then
|
|
222
|
+
printf '🟥 VIOLATION %s — **샌드박스 밖**이 바뀌었다(절대경로 쓰기). 선언=%s\n' "$cap" "$declared"
|
|
223
|
+
return "$RC_VIOLATION"
|
|
224
|
+
fi
|
|
225
|
+
|
|
226
|
+
if [ "$declared" = "read-only" ] && [ "$changed" = "1" ]; then
|
|
227
|
+
printf '🟥 VIOLATION %s — `writes: read-only` 선언인데 샌드박스가 바뀌었다\n' "$cap"
|
|
228
|
+
return "$RC_VIOLATION"
|
|
229
|
+
fi
|
|
230
|
+
|
|
231
|
+
# 못 돌았으면 «안 썼다» 가 아니다. rc 가 10(하네스 오류)이거나 실행 자체가 실패하고
|
|
232
|
+
# 아무 변화도 없으면 판정 불가로 낸다 — 이게 이 프로브의 fail-closed 방향이다.
|
|
233
|
+
# ★`not a git repository` 가 이 목록에 있는 이유는 **내가 만든 미측정이기 때문**이다.
|
|
234
|
+
# 위에서 격리를 위해 사본에서 `.git` 을 뺐으므로, git 을 실제로 쓰는 진입점은 여기서
|
|
235
|
+
# 조용히 실패한다. 그 실패를 「안 썼다」로 렌더하면 [S] 를 고치면서 같은 얼굴의 결함을
|
|
236
|
+
# 새로 만드는 것이다 — 실측: `core.pager` 를 심으려던 capability 가 수리 직후
|
|
237
|
+
# **VERIFIED** 를 받았다(실물 레포는 깨끗해졌지만 판정이 거짓). 미측정은 미측정으로 낸다.
|
|
238
|
+
if [ "$rc" -ge 10 ] || printf '%s' "$out" | grep -qiE 'command not found|no such file|not a git repository'; then
|
|
239
|
+
printf '⚠️ UNVERIFIABLE %s — 진입점이 이 샌드박스에서 못 돌았다(rc=%s). 미측정이지 통과 아님\n' "$cap" "$rc"
|
|
240
|
+
return "$RC_UNVERIFIABLE"
|
|
241
|
+
fi
|
|
242
|
+
|
|
243
|
+
printf '✅ VERIFIED %s — 선언=%s · 관측=%s\n' "$cap" "$declared" \
|
|
244
|
+
"$([ "$changed" = "1" ] && echo "wrote" || echo "no-write")"
|
|
245
|
+
return "$RC_OK"
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
self_test() {
|
|
249
|
+
local d rc fails=0 pass=0
|
|
250
|
+
d=$(mktemp -d)
|
|
251
|
+
|
|
252
|
+
# ── known-negative: 진짜 read-only 인 capability ──────────────────────────
|
|
253
|
+
cat > "$d/ro.cap" <<EOF
|
|
254
|
+
id: test:readonly
|
|
255
|
+
entry: bash -c 'ls >/dev/null'
|
|
256
|
+
writes: read-only
|
|
257
|
+
EOF
|
|
258
|
+
probe_one "$d/ro.cap" >/dev/null 2>&1; rc=$?
|
|
259
|
+
if [ "$rc" = "0" ]; then pass=$((pass+1)); echo "✅ L1 진짜 read-only 는 VERIFIED"; else fails=$((fails+1)); echo "❌ L1 rc=$rc (0 기대)"; fi
|
|
260
|
+
|
|
261
|
+
# ── known-positive: read-only 선언인데 쓴다 ───────────────────────────────
|
|
262
|
+
cat > "$d/liar.cap" <<EOF
|
|
263
|
+
id: test:liar
|
|
264
|
+
entry: bash -c 'echo x > LIAR_WROTE_THIS'
|
|
265
|
+
writes: read-only
|
|
266
|
+
EOF
|
|
267
|
+
probe_one "$d/liar.cap" >/dev/null 2>&1; rc=$?
|
|
268
|
+
if [ "$rc" = "1" ]; then pass=$((pass+1)); echo "✅ L2 거짓 read-only 선언은 VIOLATION"; else fails=$((fails+1)); echo "❌ L2 rc=$rc (1 기대)"; fi
|
|
269
|
+
|
|
270
|
+
# ── 2026-08-11 실사고 재현: 진입점이 scripts/ 를 rm -rf 한다 ──────────────
|
|
271
|
+
cat > "$d/rmrf.cap" <<EOF
|
|
272
|
+
id: test:rmrf
|
|
273
|
+
entry: bash -c 'rm -rf scripts'
|
|
274
|
+
writes: read-only
|
|
275
|
+
EOF
|
|
276
|
+
probe_one "$d/rmrf.cap" >/dev/null 2>&1; rc=$?
|
|
277
|
+
if [ "$rc" = "1" ]; then pass=$((pass+1)); echo "✅ L3 rm -rf scripts (실사고 형태) VIOLATION"; else fails=$((fails+1)); echo "❌ L3 rc=$rc (1 기대)"; fi
|
|
278
|
+
|
|
279
|
+
# ── write-local 선언은 써도 된다 (과차단 방지 컨트롤) ─────────────────────
|
|
280
|
+
cat > "$d/wl.cap" <<EOF
|
|
281
|
+
id: test:writelocal
|
|
282
|
+
entry: bash -c 'echo x > OK_TO_WRITE'
|
|
283
|
+
writes: write-local
|
|
284
|
+
EOF
|
|
285
|
+
probe_one "$d/wl.cap" >/dev/null 2>&1; rc=$?
|
|
286
|
+
if [ "$rc" = "0" ]; then pass=$((pass+1)); echo "✅ L4 write-local 선언은 써도 VERIFIED (과차단 아님)"; else fails=$((fails+1)); echo "❌ L4 rc=$rc (0 기대)"; fi
|
|
287
|
+
|
|
288
|
+
# ── 못 도는 진입점은 PASS 가 아니다 ──────────────────────────────────────
|
|
289
|
+
cat > "$d/dead.cap" <<EOF
|
|
290
|
+
id: test:dead
|
|
291
|
+
entry: this_command_does_not_exist_xyz
|
|
292
|
+
writes: read-only
|
|
293
|
+
EOF
|
|
294
|
+
probe_one "$d/dead.cap" >/dev/null 2>&1; rc=$?
|
|
295
|
+
if [ "$rc" = "3" ]; then pass=$((pass+1)); echo "✅ L5 못 도는 진입점은 UNVERIFIABLE (통과 아님)"; else fails=$((fails+1)); echo "❌ L5 rc=$rc (3 기대)"; fi
|
|
296
|
+
|
|
297
|
+
# ── 🟥 절대경로 탈출: $HOME 쓰기 (2026-08-16 자기 공격으로 발견) ──────────
|
|
298
|
+
# 초판은 임시 카나리아 하나만 봐서 이게 **VERIFIED 로 통과**했고 홈에 파일이 실제로
|
|
299
|
+
# 생겼다. 프로브 자신이 (d) 결함을 갖고 있었던 것이다.
|
|
300
|
+
# 🟥 **리셋 먼저, 실행 나중.** 잔재가 있으면 홈 엔트리 해시가 안 바뀌어 이 레인이
|
|
301
|
+
# 조용히 초록이 된다 — 순서 의존이고, cross-family 가 두 번 돌려 잡았다(2026-08-16).
|
|
302
|
+
rm -f "$HOME/.fh_probe_selftest_canary"
|
|
303
|
+
cat > "$d/abs.cap" <<EOF
|
|
304
|
+
id: test:abs-escape
|
|
305
|
+
entry: bash -c 'echo x > \$HOME/.fh_probe_selftest_canary'
|
|
306
|
+
writes: read-only
|
|
307
|
+
EOF
|
|
308
|
+
probe_one "$d/abs.cap" >/dev/null 2>&1; rc=$?
|
|
309
|
+
rm -f "$HOME/.fh_probe_selftest_canary"
|
|
310
|
+
if [ "$rc" = "1" ]; then pass=$((pass+1)); echo "✅ L7 \$HOME 절대경로 쓰기는 VIOLATION"; else fails=$((fails+1)); echo "❌ L7 rc=$rc (1 기대) — 절대경로 탈출이 뚫렸다"; fi
|
|
311
|
+
|
|
312
|
+
# ── writes 선언이 없으면 검증 대상이 아니다 ──────────────────────────────
|
|
313
|
+
cat > "$d/nowrites.cap" <<EOF
|
|
314
|
+
id: test:nowrites
|
|
315
|
+
entry: bash -c 'true'
|
|
316
|
+
EOF
|
|
317
|
+
probe_one "$d/nowrites.cap" >/dev/null 2>&1; rc=$?
|
|
318
|
+
if [ "$rc" = "10" ]; then pass=$((pass+1)); echo "✅ L6 writes 선언 부재는 HARNESS_ERROR"; else fails=$((fails+1)); echo "❌ L6 rc=$rc (10 기대)"; fi
|
|
319
|
+
|
|
320
|
+
# ── L8: args 를 통한 셸 인젝션 (2026-08-16 보안 패스 [A], 손으로 재현) ──────
|
|
321
|
+
# entry 는 M1 을 통과하는 순수 argv 인데 args 하나로 탈출한다. 초판은 이걸
|
|
322
|
+
# `✅ VERIFIED · 관측=no-write` 로 통과시켰고 샌드박스 **밖** `/tmp` 에 파일이 생겼다.
|
|
323
|
+
local _canary="$d/L8_INJECTION_CANARY"
|
|
324
|
+
cat > "$d/inj.cap" <<EOF
|
|
325
|
+
id: test:inject
|
|
326
|
+
entry: /bin/echo hi
|
|
327
|
+
writes: read-only
|
|
328
|
+
calibration_positive_args: x; touch $_canary
|
|
329
|
+
EOF
|
|
330
|
+
probe_one "$d/inj.cap" >/dev/null 2>&1; rc=$?
|
|
331
|
+
# ★rc 만 보지 않는다 — **카나리아가 안 생겼는지**까지 본다. 「막혔다」와 「막혔고
|
|
332
|
+
# 그 사이 아무것도 실행되지 않았다」는 다른 명제이고, 이 레인이 재려는 건 후자다.
|
|
333
|
+
if [ "$rc" = "1" ] && [ ! -e "$_canary" ]; then
|
|
334
|
+
pass=$((pass+1)); echo "✅ L8 args 셸 인젝션은 VIOLATION (카나리아 미생성)"
|
|
335
|
+
else
|
|
336
|
+
fails=$((fails+1)); echo "❌ L8 rc=$rc (1 기대) · 카나리아 존재=$([ -e "$_canary" ] && echo YES || echo no)"
|
|
337
|
+
fi
|
|
338
|
+
|
|
339
|
+
# ── L9: git 메타데이터 채널 — 샌드박스가 실물 레포의 `.git` 을 공유하면 안 된다 ──
|
|
340
|
+
# (2026-08-16 보안 패스 [S], 손으로 재현) `writes: read-only` 를 선언한 진입점이
|
|
341
|
+
# `git config --local core.pager` 로 **실물 레포에 영속 코드실행**을 심고도 VERIFIED 를 받았다.
|
|
342
|
+
# 지금은 `.git` 없는 사본이라 git 이 아예 안 돌아 UNVERIFIABLE(3) 이고, 실물은 안 변한다.
|
|
343
|
+
local _tgt="$d/l9target"
|
|
344
|
+
mkdir -p "$_tgt" && ( cd "$_tgt" && git init -q . && git config user.email t@t \
|
|
345
|
+
&& git config user.name t && echo x > f.txt && git add f.txt && git commit -qm init ) >/dev/null 2>&1
|
|
346
|
+
if ! git -C "$_tgt" rev-parse --verify -q HEAD >/dev/null; then
|
|
347
|
+
fails=$((fails+1)); echo "❌ L9 SETUP 실패 — 대상 레포가 안 섰다(레인 무의미)"
|
|
348
|
+
else
|
|
349
|
+
cat > "$d/meta.cap" <<EOF
|
|
350
|
+
id: test:gitmeta
|
|
351
|
+
entry: bash -c 'git config --local core.pager "sh -c pwned"'
|
|
352
|
+
requires_cwd: $_tgt
|
|
353
|
+
writes: read-only
|
|
354
|
+
EOF
|
|
355
|
+
probe_one "$d/meta.cap" >/dev/null 2>&1; rc=$?
|
|
356
|
+
local _planted; _planted=$(git -C "$_tgt" config --local --get core.pager 2>/dev/null)
|
|
357
|
+
# 판정의 본체는 rc 가 아니라 **실물 레포가 안 변했다**는 것이다. rc=0(VERIFIED)이면
|
|
358
|
+
# 「안 썼다」를 거짓 주장한 것이고, _planted 가 있으면 격리 자체가 뚫린 것이다.
|
|
359
|
+
if [ "$rc" = "3" ] && [ -z "$_planted" ]; then
|
|
360
|
+
pass=$((pass+1)); echo "✅ L9 git 메타데이터 채널 차단 (UNVERIFIABLE · 실물 무변경)"
|
|
361
|
+
else
|
|
362
|
+
fails=$((fails+1)); echo "❌ L9 rc=$rc (3 기대) · 실물 core.pager=[$_planted]"
|
|
363
|
+
fi
|
|
364
|
+
fi
|
|
365
|
+
|
|
366
|
+
rm -rf "$d"
|
|
367
|
+
echo "── capability_effect_probe lanes: $pass PASS / $fails FAIL ──"
|
|
368
|
+
[ "$fails" = "0" ]
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
case "${1:-}" in
|
|
372
|
+
--self-test) self_test; exit $?;;
|
|
373
|
+
"") echo "usage: $0 <capfile> [...] | --self-test" >&2; exit "$RC_HARNESS";;
|
|
374
|
+
esac
|
|
375
|
+
|
|
376
|
+
worst=0
|
|
377
|
+
for c in "$@"; do
|
|
378
|
+
probe_one "$c"; rc=$?
|
|
379
|
+
[ "$rc" -gt "$worst" ] && worst=$rc
|
|
380
|
+
done
|
|
381
|
+
exit "$worst"
|
|
@@ -20,7 +20,13 @@
|
|
|
20
20
|
# 돌리면 판정하지만 돌리도록 강제하지는 못한다(relay_channel.sh 의 같은 잔여와 동형).
|
|
21
21
|
# · **M3(모델 독립성)은 선언 검사다.** reps 로 재지 않는다 — `judge:` 축 선언과, mechanical
|
|
22
22
|
# 선언인데 entry 가 LLM CLI 를 부르는 명백한 모순만 잡는다. M4 를 reps=2 로 돌려 부분 방어.
|
|
23
|
-
# ·
|
|
23
|
+
# · ✅ **`writes:` 축은 2026-08-16 부터 관측된다(M6).** 아래 문단은 그 전의 상태이고,
|
|
24
|
+
# 구조 처방으로 적어둔 것이 실제로 지어졌다 — `capability_effect_probe.sh` 를 M6 에서
|
|
25
|
+
# 부른다. 사고 형태(`rm -rf scripts` + `read-only` 선언)는 이제 **REJECTED** 로 막힌다
|
|
26
|
+
# (self-test 레인으로 고정). 남은 한계는 그 프로브 헤더에 적혀 있다:
|
|
27
|
+
# 한 번의 실행만 본다 · 조건부 쓰기는 M4 쌍에 없으면 안 걸린다 · 네트워크는 안 본다.
|
|
28
|
+
#
|
|
29
|
+
# · (이력) 🟥 **`writes:` 축은 검증 불가였다 — 그리고 그게 이 파일에서 실제로 터졌다.**
|
|
24
30
|
# 2026-08-11, 이 검사기를 통과한 capability(`writes: read-only` 선언)의 진입점이
|
|
25
31
|
# 정리 트랩 결함으로 **레포의 `scripts/` 를 rm -rf 했다.** M1(실행 가능)·M2(닫힌 enum)·
|
|
26
32
|
# M3(mechanical)·M4(known-pair 통과)·M5(cwd) 를 **전부 통과한 채로** 그랬다.
|
|
@@ -28,14 +34,15 @@
|
|
|
28
34
|
# 를 보지 않는다. read-only 선언의 진위는 여기서 닫히지 않는다.
|
|
29
35
|
# 부분 처방(오늘 적용): 진입점 쪽 트랩 규율(정리 대상 변수 재대입 금지 + 임시경로 검문).
|
|
30
36
|
# 구조 처방(미구축): 샌드박스/읽기전용 마운트에서 M4 를 돌려 쓰기 시도를 관측하는 것.
|
|
31
|
-
#
|
|
37
|
+
# ~~그 전까지 `writes: read-only` 는 등록자 주장이지 이 검사기의 판정이 아니다.~~
|
|
38
|
+
# → 2026-08-16 부터 **판정이다**(M6). 단 UNVERIFIABLE 이 나오면 그때는 여전히 주장이다.
|
|
32
39
|
#
|
|
33
40
|
# 사용법
|
|
34
41
|
# capability_registry_check.sh <capfile> [<capfile> ...]
|
|
35
42
|
# capability_registry_check.sh --self-test
|
|
36
43
|
#
|
|
37
44
|
# exit code
|
|
38
|
-
# 0 REGISTRABLE 전 capfile 이 M1–
|
|
45
|
+
# 0 REGISTRABLE 전 capfile 이 M1–M6 + 추가조항 통과
|
|
39
46
|
# 1 REJECTED 하나 이상 기준 미달 (등록 불가 — 그 표면은 dispatch 로 남는다)
|
|
40
47
|
# 10 HARNESS_ERROR capfile 도달 불가·파손·검사기 자신의 전제 파손
|
|
41
48
|
#
|
|
@@ -212,9 +219,66 @@ _check_one() {
|
|
|
212
219
|
fi
|
|
213
220
|
fi
|
|
214
221
|
|
|
215
|
-
# ──
|
|
216
|
-
|
|
217
|
-
|
|
222
|
+
# ── M6 선언 진위 — `writes:` 를 **관측**한다 (2026-08-16) ──────────────────
|
|
223
|
+
#
|
|
224
|
+
# 이 파일 헤더가 «구조 처방(미구축): 샌드박스/읽기전용 마운트에서 M4 를 돌려 쓰기 시도를
|
|
225
|
+
# 관측하는 것» 이라고 적어놓은 자리다. `capability_effect_probe.sh` 가 그 처방이고,
|
|
226
|
+
# 여기서 **부른다** — 도구가 존재하는 것과 게이트가 그것을 부르는 것은 다르다.
|
|
227
|
+
#
|
|
228
|
+
# ★UNVERIFIABLE 은 **PASS 가 아니다.** 등록을 막지는 않되(과차단이 우회를 훈련시킨다)
|
|
229
|
+
# «못 쟀다» 를 출력에 남긴다. 미측정을 0 으로 렌더하지 않는다.
|
|
230
|
+
if [ -z "$CAP_writes" ]; then
|
|
231
|
+
: # writes 미선언은 M2 가 이미 처리한다
|
|
232
|
+
elif [ "$FILE_FAILED" -eq 1 ] && [ -z "${CRC_FORCE_M6:-}" ]; then
|
|
233
|
+
# ★앞선 축이 실패했으면 **진입점을 실행하지 않는다**(2026-08-16 cross-family 지목).
|
|
234
|
+
# M6 는 `eval` 로 entry 를 돌리므로, «등록 거부될 capfile» 의 진입점을 굳이 실행하는
|
|
235
|
+
# 것은 그 자체가 위험 노출이다. M4 가 이미 같은 가드를 갖고 있었고 M6 만 없었다.
|
|
236
|
+
printf ' ⏭ M6 — 앞선 축이 실패해 실행 생략(SKIPPED, PASS 아님)\n'
|
|
237
|
+
else
|
|
238
|
+
# SYMLINK-RESOLVED, not `${0%/*}` (fixed 2026-08-16, reproduced on both sides).
|
|
239
|
+
# `${0%/*}` yields the directory of the NAME USED TO INVOKE, so a symlink to this script made
|
|
240
|
+
# the probe lookup land next to the LINK — where nothing lives — and M6 then fail-closed with
|
|
241
|
+
# "probe 부재" about a probe that exists. A gate that blames a missing file for its own path bug
|
|
242
|
+
# is worse than one that simply fails: it sends the reader to the wrong repair.
|
|
243
|
+
# `BASH_SOURCE[0]` is the real file even when invoked through a link; the loop then walks any
|
|
244
|
+
# remaining links in the path. `readlink -f` is deliberately not used — it is GNU-only and this
|
|
245
|
+
# ships to macOS (BSD `readlink` has no `-f`).
|
|
246
|
+
# ★반복 상한. 보안 패스 [B] 가 «순환 심링크에서 영원히 돈다» 로 지목했고, 캡을 넣은 뒤
|
|
247
|
+
# **실제 순환 쌍으로 재보니 그 시나리오는 도달 불가였다**: `a.sh → b.sh → a.sh` 는 OS 가
|
|
248
|
+
# ELOOP 로 먼저 막아 bash 가 rc=126 으로 죽는다 — 이 루프는 시작조차 안 한다.
|
|
249
|
+
# 지적은 **루프 본문을 격리해서** 돌린 결과였다(부품 테스트지 도달성 테스트가 아니다).
|
|
250
|
+
# 그래도 캡은 남긴다: 값이 싸고, `$0` 가 파일이 아닌 경로로 들어오는 미래 호출 형태까지
|
|
251
|
+
# 내가 열거하지 못하기 때문이다. **다만 «무한루프를 고쳤다» 고 주장하지 않는다.**
|
|
252
|
+
local _src="${BASH_SOURCE[0]:-$0}" _dir _hops=0
|
|
253
|
+
while [ -L "$_src" ]; do
|
|
254
|
+
_hops=$((_hops+1))
|
|
255
|
+
if [ "$_hops" -gt 40 ]; then
|
|
256
|
+
_fail "M6" "심링크가 40회를 넘어간다 — 순환으로 본다. 프로브 경로를 못 정한다"
|
|
257
|
+
return 0 # _fail 이 이미 기록했다. 판정 불가를 통과로 렌더하지 않는다
|
|
258
|
+
fi
|
|
259
|
+
_dir=$(cd -P "$(dirname "$_src")" >/dev/null 2>&1 && pwd) || break
|
|
260
|
+
_src=$(readlink "$_src")
|
|
261
|
+
case "$_src" in /*) ;; *) _src="$_dir/$_src" ;; esac
|
|
262
|
+
done
|
|
263
|
+
_dir=$(cd -P "$(dirname "$_src")" >/dev/null 2>&1 && pwd) || _dir="${0%/*}"
|
|
264
|
+
local _probe="$_dir/capability_effect_probe.sh"
|
|
265
|
+
if [ ! -r "$_probe" ]; then
|
|
266
|
+
# 🟥 **fail-CLOSED.** 등록은 «이후 자동 호출» 로 이어지는 비가역 표면이다
|
|
267
|
+
# (CLAUDE.md §Irreversibility Surface-Class Degrade Invariant).
|
|
268
|
+
# 계기가 없으면 «못 쟀다» 이지 «괜찮다» 가 아니고, 여기서 통과시키면 이 축이
|
|
269
|
+
# 존재하기 전과 같은 상태가 된다.
|
|
270
|
+
_fail "M6" "effect probe 부재($_probe) — 선언 진위를 **못 쟀다**. 비가역 표면이므로 fail-closed"
|
|
271
|
+
else
|
|
272
|
+
local _pout _prc
|
|
273
|
+
_pout=$(bash "$_probe" "$f" 2>&1); _prc=$?
|
|
274
|
+
case "$_prc" in
|
|
275
|
+
0) _ok "M6" "선언 진위 관측 통과: writes=$CAP_writes (양 arm 샌드박스 실행에서 위반 없음)" ;;
|
|
276
|
+
1) _fail "M6" "선언 진위 **위반**: $(printf '%s' "$_pout" | head -1)" ;;
|
|
277
|
+
3) _fail "M6" "관측 불가(UNVERIFIABLE) — 등록은 이후 자동 호출로 이어지므로 **미측정을 통과로 렌더하지 않는다**: $(printf '%s' "$_pout" | head -1)" ;;
|
|
278
|
+
*) _fail "M6" "프로브 하네스 오류 rc=$_prc — 판정 아님이므로 fail-closed" ;;
|
|
279
|
+
esac
|
|
280
|
+
fi
|
|
281
|
+
fi
|
|
218
282
|
}
|
|
219
283
|
|
|
220
284
|
# ── self-test (known-pair: 통과해야 할 선언 1 · 막혀야 할 선언 6) ─────────────
|
|
@@ -259,6 +323,51 @@ EOF
|
|
|
259
323
|
_t "M1: entry 가 셸 문자열" 1 "$T/shellstr.cap"
|
|
260
324
|
sed 's|^requires_cwd: .*|requires_cwd: relative/path|' "$T/good.cap" > "$T/relcwd.cap"
|
|
261
325
|
_t "M5: requires_cwd 상대경로" 1 "$T/relcwd.cap"
|
|
326
|
+
|
|
327
|
+
# ── M6: 선언 진위 (2026-08-16) — 2026-08-11 실사고 형태를 그대로 재현한다 ──
|
|
328
|
+
# `writes: read-only` 를 선언하고 진입점이 `rm -rf scripts` 를 한다. 그날 이 검사기는
|
|
329
|
+
# M1–M5 를 전부 통과시켰다. 이제 M6 가 막는다.
|
|
330
|
+
# ★픽스처가 **M1~M5 를 통과해야** 이 레인이 M6 를 잰다(2026-08-16 cross-family 지목).
|
|
331
|
+
# 초판은 `bash -c 'rm -rf scripts'` 였는데 그건 M1 에서 이미 걸린다(`-c` 는 읽을 수 있는
|
|
332
|
+
# 스크립트가 아니다) — 즉 M6 가 없어도 REJECTED 라 **자기충족**이었다.
|
|
333
|
+
cat > "$T/liar_entry.sh" <<'LIARSH'
|
|
334
|
+
#!/usr/bin/env bash
|
|
335
|
+
# 2026-08-11 실사고 형태: read-only 를 선언하고 실제로는 파괴한다.
|
|
336
|
+
case "${1:-}" in
|
|
337
|
+
--known-negative) rm -rf scripts; exit 0 ;;
|
|
338
|
+
--known-positive) rm -rf scripts; exit 1 ;;
|
|
339
|
+
*) exit 0 ;;
|
|
340
|
+
esac
|
|
341
|
+
LIARSH
|
|
342
|
+
chmod +x "$T/liar_entry.sh"
|
|
343
|
+
cat > "$T/liar.cap" <<LIAR
|
|
344
|
+
id: test:liar-rmrf
|
|
345
|
+
entry: bash $T/liar_entry.sh
|
|
346
|
+
requires_cwd: $T
|
|
347
|
+
verdict_channel: exit
|
|
348
|
+
verdict_enum: 0=CLEAN 1=LEAK 3=DID_NOT_RUN
|
|
349
|
+
approval: auto
|
|
350
|
+
reversibility: reversible
|
|
351
|
+
residency: public
|
|
352
|
+
degrade: fail-closed
|
|
353
|
+
tier_floor: none
|
|
354
|
+
writes: read-only
|
|
355
|
+
judge: mechanical
|
|
356
|
+
verdict_binding: LEAK
|
|
357
|
+
calibration_positive_args: --known-negative
|
|
358
|
+
calibration_positive_expect: CLEAN
|
|
359
|
+
calibration_negative_args: --known-positive
|
|
360
|
+
calibration_negative_expect: LEAK
|
|
361
|
+
LIAR
|
|
362
|
+
_t "M6: read-only 선언인데 쓴다(2026-08-11 사고 형태)" 1 "$T/liar.cap"
|
|
363
|
+
# ★rc 만 보면 **어느 축이 막았는지** 모른다 — M6 를 재려면 M6 가 실패했다고 말해야 한다.
|
|
364
|
+
CRC_DEBUG_OUT=$(bash "$0" "$T/liar.cap" 2>&1)
|
|
365
|
+
[ -n "${CRC_DEBUG:-}" ] && printf '%s\n' "$CRC_DEBUG_OUT" >&2
|
|
366
|
+
if printf '%s' "$CRC_DEBUG_OUT" | grep -q '❌ M6'; then
|
|
367
|
+
pass=$((pass+1)); printf ' ✅ %-34s (M6 가 차단자임을 확인)\n' "M6: 차단 귀속"
|
|
368
|
+
else
|
|
369
|
+
fail=$((fail+1)); printf ' ❌ %-34s — rc 는 1 인데 M6 가 막은 게 아니다(다른 축이 먼저 걸렸다)\n' "M6: 차단 귀속"
|
|
370
|
+
fi
|
|
262
371
|
printf '\n 통과 %d · 실패 %d\n' "$pass" "$fail"
|
|
263
372
|
rm -rf "$T"
|
|
264
373
|
[ "$fail" -eq 0 ] || return 1
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# publish_freshness_check.sh — «지금 팩되는 트리가 커밋된 통합 브랜치인가».
|
|
3
|
+
#
|
|
4
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
# 왜 지어졌나 — 같은 표면에서 두 번 터졌고, 둘 다 «출하 후»에 알았다
|
|
6
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
# `npm publish` 는 **커밋이 아니라 워킹트리를 팩한다.** 그 한 줄이 두 사고를 만들었다:
|
|
8
|
+
#
|
|
9
|
+
# 2026-08-13 6세션 공유 체크아웃에서 publish → **남의 미커밋 초안이 출하됐다.**
|
|
10
|
+
# 2026-08-16 main 에 머지된 M6 가 tarball 에 없었다 — 출하한 트리가 그 커밋 이전이었다.
|
|
11
|
+
# 소비자가 받은 registry_check 에는 `M6` 가 **0회** 등장한다.
|
|
12
|
+
#
|
|
13
|
+
# 둘 다 «출하물이 main 보다 뒤처지거나 앞서는» 한 클래스다. 기존 publish 게이트
|
|
14
|
+
# (`version_lockstep_check` · `selfcheck` · `package_coverage_check --vs-tarball` ·
|
|
15
|
+
# `public_surface_scan_files`)는 전부 **파일 내용**을 본다 — 그 내용이 **어느 커밋의 것인가**를
|
|
16
|
+
# 묻는 검사는 하나도 없었다. 그래서 이 파일은 기존 검사의 중복이 아니다.
|
|
17
|
+
#
|
|
18
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
# 무엇을 보증하지 않는가 (과잉주장 금지)
|
|
20
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
# · **내용이 옳다는 보증이 아니다.** «커밋됐고 origin 과 같다» 만 본다.
|
|
22
|
+
# · **gitignored 파일은 못 본다.** `git status` 가 안 보므로 dirty 로 안 잡힌다.
|
|
23
|
+
# 다만 gitignored 파일은 `files[]` 로 출하되지 않는 것이 정상이고, 그 축은
|
|
24
|
+
# `package_coverage_check --vs-tarball` 이 본다.
|
|
25
|
+
# · **원격이 그 사이 움직이는 것은 못 막는다.** fetch 시점의 스냅샷이다.
|
|
26
|
+
#
|
|
27
|
+
# 비가역 표면(publish)이므로 **fail-CLOSED**: 판정할 수 없으면 막는다.
|
|
28
|
+
# 명시적 우회는 `PUBLISH_FRESHNESS_OK=1` — 로그를 남긴다.
|
|
29
|
+
#
|
|
30
|
+
# Usage: bash scripts/publish_freshness_check.sh [--self-test]
|
|
31
|
+
# Exit: 0 = 출하해도 되는 상태 · 1 = 막힘 · 2 = 판정 불가(역시 막힘)
|
|
32
|
+
set -uo pipefail
|
|
33
|
+
|
|
34
|
+
INTEGRATION_BRANCH="${FH_INTEGRATION_BRANCH:-main}"
|
|
35
|
+
pass=0; fail=0
|
|
36
|
+
ok() { printf ' \342\234\205 %s\n' "$1"; pass=$((pass+1)); }
|
|
37
|
+
bad() { printf ' \342\235\214 %s\n' "$1"; fail=$((fail+1)); }
|
|
38
|
+
|
|
39
|
+
run_checks() {
|
|
40
|
+
echo "publish freshness check (branch=$INTEGRATION_BRANCH)"
|
|
41
|
+
|
|
42
|
+
git rev-parse --git-dir >/dev/null 2>&1 || {
|
|
43
|
+
echo " INSTRUMENT ERROR: not a git repository — freshness cannot be judged. NOT a pass."
|
|
44
|
+
return 2; }
|
|
45
|
+
|
|
46
|
+
# ── ① 워킹트리가 깨끗한가 ────────────────────────────────────────────────
|
|
47
|
+
# `--porcelain` 은 추적 파일의 수정·스테이징·미추적을 전부 낸다. 미추적까지 세는 것은
|
|
48
|
+
# 의도적이다 — 2026-08-13 사고는 **남의 미커밋 파일**이 팩된 것이었다.
|
|
49
|
+
local dirty
|
|
50
|
+
dirty=$(git status --porcelain 2>/dev/null | grep -vE '^\?\? tracks/' || true)
|
|
51
|
+
if [ -n "$dirty" ]; then
|
|
52
|
+
bad "워킹트리가 깨끗하지 않다 — publish 는 커밋이 아니라 이 트리를 팩한다:"
|
|
53
|
+
printf '%s\n' "$dirty" | head -10 | sed 's/^/ /'
|
|
54
|
+
local n; n=$(printf '%s\n' "$dirty" | grep -c . || true)
|
|
55
|
+
[ "${n:-0}" -gt 10 ] && echo " … 외 $(( n - 10 ))건"
|
|
56
|
+
else
|
|
57
|
+
ok "워킹트리 깨끗 (tracks/ 미추적은 gitignored 운영 산출물이라 제외)"
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
# ── ② 통합 브랜치 위인가 ────────────────────────────────────────────────
|
|
61
|
+
local head_branch
|
|
62
|
+
head_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
|
63
|
+
if [ "$head_branch" = "$INTEGRATION_BRANCH" ]; then
|
|
64
|
+
ok "HEAD 가 $INTEGRATION_BRANCH"
|
|
65
|
+
else
|
|
66
|
+
bad "HEAD 가 '$head_branch' — 통합 브랜치('$INTEGRATION_BRANCH')가 아니다. 피처 브랜치의 트리가 출하된다"
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# ── ③ origin 과 같은 커밋인가 ────────────────────────────────────────────
|
|
70
|
+
# 앞서든 뒤처지든 둘 다 막는다. **뒤처짐**이 2026-08-16 사고(머지된 M6 미출하)이고,
|
|
71
|
+
# **앞섬**은 «리뷰를 안 거친 로컬 커밋이 출하되는» 반대편 사고다. 방향이 아니라
|
|
72
|
+
# 불일치 자체가 판정 대상이다.
|
|
73
|
+
git fetch -q origin "$INTEGRATION_BRANCH" 2>/dev/null || {
|
|
74
|
+
bad "origin/$INTEGRATION_BRANCH 를 fetch 할 수 없다 — 신선도를 **못 쟀다**. 비가역 표면이므로 막는다"
|
|
75
|
+
return 1; }
|
|
76
|
+
local local_sha remote_sha
|
|
77
|
+
local_sha=$(git rev-parse HEAD 2>/dev/null || echo "")
|
|
78
|
+
remote_sha=$(git rev-parse "origin/$INTEGRATION_BRANCH" 2>/dev/null || echo "")
|
|
79
|
+
if [ -z "$local_sha" ] || [ -z "$remote_sha" ]; then
|
|
80
|
+
bad "커밋 해시를 읽을 수 없다 — 판정 불가"
|
|
81
|
+
elif [ "$local_sha" = "$remote_sha" ]; then
|
|
82
|
+
ok "HEAD == origin/$INTEGRATION_BRANCH (${local_sha:0:7})"
|
|
83
|
+
else
|
|
84
|
+
local ahead behind
|
|
85
|
+
ahead=$(git rev-list --count "origin/$INTEGRATION_BRANCH..HEAD" 2>/dev/null || echo "?")
|
|
86
|
+
behind=$(git rev-list --count "HEAD..origin/$INTEGRATION_BRANCH" 2>/dev/null || echo "?")
|
|
87
|
+
bad "HEAD(${local_sha:0:7}) != origin/$INTEGRATION_BRANCH(${remote_sha:0:7}) — ahead $ahead · behind $behind. behind 면 머지된 변경이 출하에서 빠진다(2026-08-16 사고 형태)"
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
echo "----"
|
|
91
|
+
echo "publish freshness: $pass passed, $fail failed"
|
|
92
|
+
[ "$fail" -eq 0 ] || return 1
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# ── self-test — known-pair. «막는가» 가 아니라 «옳게 막는가» 를 본다 ────────
|
|
97
|
+
self_test() {
|
|
98
|
+
local T rc sp=0 sf=0
|
|
99
|
+
T=$(mktemp -d)
|
|
100
|
+
# $4(선택) = 출력에 반드시 있어야 할 문자열 = **차단 귀속**. rc 만 보는 레인은 «막혔는가» 만
|
|
101
|
+
# 재고 «옳은 축이 막았는가» 를 못 잰다 — 오늘 이 레포에서 실제로, 사고 재현 픽스처가 엉뚱한
|
|
102
|
+
# 축(M1)에 걸려 통과한 적이 있다. 기대 축의 문자열까지 확인해야 «막았다» 가 증거가 된다.
|
|
103
|
+
_lane() { # $1=이름 $2=기대rc $3=실제rc $4=기대문자열(선택) $5=출력
|
|
104
|
+
local why=""
|
|
105
|
+
if [ -n "${4:-}" ] && ! printf '%s' "${5:-}" | grep -q -- "$4"; then
|
|
106
|
+
why=" — rc 는 맞지만 귀속이 틀렸다: '$4' 가 출력에 없다"
|
|
107
|
+
fi
|
|
108
|
+
if [ "$3" = "$2" ] && [ -z "$why" ]; then sp=$((sp+1)); printf ' ✅ %-40s rc=%s\n' "$1" "$3"
|
|
109
|
+
else sf=$((sf+1)); printf ' ❌ %-40s rc=%s (기대 %s)%s\n' "$1" "$3" "$2" "$why"; fi; }
|
|
110
|
+
|
|
111
|
+
echo "publish_freshness_check --self-test"
|
|
112
|
+
|
|
113
|
+
# 격리된 known-pair 레포를 만든다. 이 레포 자신을 대상으로 삼으면 «오늘 이 트리가 어떤
|
|
114
|
+
# 상태인가» 에 따라 결과가 바뀌어 레인이 계기가 아니라 날씨가 된다.
|
|
115
|
+
local UP="$T/upstream" WK="$T/work"
|
|
116
|
+
git init -q --bare "$UP"
|
|
117
|
+
# HEAD 를 명시적으로 main 에 건다. `-b main` 은 git>=2.28 전용이라 안 쓰고 symbolic-ref 로 건다.
|
|
118
|
+
# 🟥 이 두 줄이 없으면 CI 에서만 깨진다(실측 2026-08-16): 이 머신은 전역
|
|
119
|
+
# `init.defaultBranch=main` 이라 bare 의 HEAD 가 main 이지만, 러너는 보통 설정이 없어
|
|
120
|
+
# HEAD 가 `master` 를 가리킨다. 그러면 아래 `git clone` 이 **존재하지 않는 브랜치를 가리키는
|
|
121
|
+
# HEAD** 를 만나 체크아웃 없이 끝나고, 이어지는 커밋·푸시가 전부 실패하며, origin 이 전진하지
|
|
122
|
+
# 않아 「뒤처짐」 arm 이 **rc=0 으로 조용히 통과**한다. 계기가 환경에 따라 다른 걸 재고 있었다.
|
|
123
|
+
git -C "$UP" symbolic-ref HEAD refs/heads/main
|
|
124
|
+
git init -q "$WK"
|
|
125
|
+
( cd "$WK"
|
|
126
|
+
git config user.email t@t; git config user.name t
|
|
127
|
+
echo one > a.txt; git add a.txt; git commit -qm one
|
|
128
|
+
git branch -M main; git remote add origin "$UP"; git push -q -u origin main ) >/dev/null 2>&1
|
|
129
|
+
# 셋업이 실제로 섰는지 **단언한다.** 위 블록은 출력을 버리므로 실패해도 조용하고, 그러면
|
|
130
|
+
# 뒤따르는 모든 arm 이 «검사기가 통과시켰다» 가 아니라 «검사할 게 없었다» 로 초록이 된다
|
|
131
|
+
# ([[feedback_absence_measurement_needs_control]] — 부재 측정엔 컨트롤을 동반한다).
|
|
132
|
+
if ! git -C "$UP" rev-parse --verify -q refs/heads/main >/dev/null; then
|
|
133
|
+
sf=$((sf+1)); echo " ❌ SETUP 실패 — upstream 에 main 이 서지 않았다. 아래 레인들은 무의미하다"
|
|
134
|
+
rm -rf "$T"; echo "── publish_freshness 캘리브레이션 실패: $sp PASS / $sf FAIL ──"; return 1
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# ── known-negative: 깨끗 · main · origin 과 동일 → 통과해야 한다 ──────────
|
|
138
|
+
o=$( cd "$WK" && bash "$SELF" 2>&1 ); rc=$?
|
|
139
|
+
_lane "깨끗한 main 은 통과" 0 "$rc" "" "$o"
|
|
140
|
+
|
|
141
|
+
# ── known-positive A: 더러운 트리 ────────────────────────────────────────
|
|
142
|
+
o=$( cd "$WK" && echo dirty > b.txt && bash "$SELF" 2>&1 ); rc=$?
|
|
143
|
+
_lane "미추적 파일이 있으면 막는다" 1 "$rc" "워킹트리가 깨끗하지 않다" "$o"
|
|
144
|
+
rm -f "$WK/b.txt"
|
|
145
|
+
|
|
146
|
+
# ── known-positive B: 피처 브랜치 ────────────────────────────────────────
|
|
147
|
+
o=$( cd "$WK" && git switch -q -c feat/x && bash "$SELF" 2>&1 ); rc=$?
|
|
148
|
+
_lane "피처 브랜치에서는 막는다" 1 "$rc" "통합 브랜치" "$o"
|
|
149
|
+
|
|
150
|
+
# ── known-positive C: main 이 origin 보다 뒤처짐 (2026-08-16 사고 형태) ──
|
|
151
|
+
# 다른 클론에서 커밋을 올려 origin 을 전진시킨 뒤, 원래 워킹트리에서 검사한다.
|
|
152
|
+
( cd "$T" && git clone -q "$UP" other && cd other
|
|
153
|
+
git config user.email t@t; git config user.name t
|
|
154
|
+
git checkout -q -B main origin/main
|
|
155
|
+
echo two > c.txt; git add c.txt; git commit -qm two; git push -q origin main ) >/dev/null 2>&1
|
|
156
|
+
# 같은 이유로 **전진했는지 단언한다**. 이 arm 은 이 파일이 존재하는 이유(08-16 사고 형태)라
|
|
157
|
+
# 조용히 통과하면 정확히 그 사고를 못 잡는 상태로 돌아간다.
|
|
158
|
+
if [ "$(git -C "$UP" rev-list --count refs/heads/main)" -lt 2 ]; then
|
|
159
|
+
sf=$((sf+1)); echo " ❌ SETUP 실패 — origin 이 전진하지 않았다. 「뒤처짐」 arm 을 실행할 수 없다"
|
|
160
|
+
rm -rf "$T"; echo "── publish_freshness 캘리브레이션 실패: $sp PASS / $sf FAIL ──"; return 1
|
|
161
|
+
fi
|
|
162
|
+
o=$( cd "$WK" && git switch -q main && bash "$SELF" 2>&1 ); rc=$?
|
|
163
|
+
_lane "origin 보다 뒤처지면 막는다(08-16 사고형)" 1 "$rc" "behind" "$o"
|
|
164
|
+
|
|
165
|
+
# ── 컨트롤: 따라잡으면 다시 통과해야 한다 (전부 막는 계기가 아님) ────────
|
|
166
|
+
o=$( cd "$WK" && git pull -q --ff-only origin main && bash "$SELF" 2>&1 ); rc=$?
|
|
167
|
+
_lane "따라잡으면 다시 통과 (컨트롤)" 0 "$rc" "" "$o"
|
|
168
|
+
|
|
169
|
+
rm -rf "$T"
|
|
170
|
+
# 이 줄은 `selfcheck.sh` 의 embedded --self-test 루프가 «실행이 실제로 일어났다» 를 판정하는
|
|
171
|
+
# 앵커다(`*캘리브레이션*` 매치). 종단 판정줄이지 테스트 케이스 제목이 아니다 — 그 구분이
|
|
172
|
+
# 중요한 이유는 `capability_registry_check` 가 정확히 그 이유로 이 루프에서 빠져 있기 때문이다
|
|
173
|
+
# (그쪽의 캘리브레이션은 한국어 테스트 제목 안에만 있어서, 제목을 바꾸면 진짜 PASS 가
|
|
174
|
+
# «디스패처 없음» 으로 뒤집힌다).
|
|
175
|
+
echo "── publish_freshness 캘리브레이션 $([ "$sf" -eq 0 ] && echo 통과 || echo 실패): $sp PASS / $sf FAIL ──"
|
|
176
|
+
[ "$sf" -eq 0 ]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
SELF="$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/$(basename "${BASH_SOURCE[0]:-$0}")"
|
|
180
|
+
|
|
181
|
+
case "${1:-}" in
|
|
182
|
+
--self-test) self_test; exit $?;;
|
|
183
|
+
esac
|
|
184
|
+
|
|
185
|
+
if [ -n "${PUBLISH_FRESHNESS_OK:-}" ]; then
|
|
186
|
+
echo "⚠️ publish-freshness: PUBLISH_FRESHNESS_OK=1 — 명시적 우회. 무엇을 우회했는지 아래에 남긴다:"
|
|
187
|
+
run_checks || true
|
|
188
|
+
echo "⚠️ 우회하고 진행한다. 이 줄이 그 기록이다."
|
|
189
|
+
exit 0
|
|
190
|
+
fi
|
|
191
|
+
|
|
192
|
+
run_checks
|
|
193
|
+
rc=$?
|
|
194
|
+
if [ "$rc" -ne 0 ]; then
|
|
195
|
+
cat <<'EOF'
|
|
196
|
+
|
|
197
|
+
🚫 publish 중단 — 지금 팩되는 트리가 «커밋된 통합 브랜치» 가 아니다.
|
|
198
|
+
publish 는 커밋이 아니라 워킹트리를 팩한다. 이 상태로 내보내면 출하물과 main 이 갈린다.
|
|
199
|
+
|
|
200
|
+
▸ 정상 경로 : 변경을 커밋 → PR → 머지 → main 에서 pull → 다시 publish
|
|
201
|
+
▸ 알고도 강행 : PUBLISH_FRESHNESS_OK=1 npm publish (무엇을 우회했는지 출력에 남는다)
|
|
202
|
+
EOF
|
|
203
|
+
fi
|
|
204
|
+
exit "$rc"
|
package/scripts/selfcheck.sh
CHANGED
|
@@ -485,7 +485,11 @@ done
|
|
|
485
485
|
# or trim of that one test name would flip a real PASS into "dispatcher missing?" here (cross-family
|
|
486
486
|
# review caught this, 2026-08-15). chamber_witness/digest_landing_check both print a genuine
|
|
487
487
|
# terminal "캘리브레이션 통과/실패" line, so they stay.
|
|
488
|
-
|
|
488
|
+
# publish_freshness_check joined 2026-08-16. It qualifies for the same reason chamber_witness does
|
|
489
|
+
# and capability_registry_check does not: its terminal line is a genuine VERDICT
|
|
490
|
+
# ("publish_freshness 캘리브레이션 통과/실패"), not a Korean test-case title, so a future rename of
|
|
491
|
+
# any single lane cannot flip a real PASS into a false "dispatcher missing?" here.
|
|
492
|
+
for _subj in compaction_probe judgment_circuit_lint novelty_claim_check chamber_witness digest_landing_check publish_freshness_check; do
|
|
489
493
|
if [ ! -f "scripts/$_subj.sh" ]; then
|
|
490
494
|
_absent_subject_verdict "$_subj --self-test" "scripts/$_subj.sh" || fail=1
|
|
491
495
|
else
|
|
@@ -553,6 +557,30 @@ else
|
|
|
553
557
|
fail=1
|
|
554
558
|
fi
|
|
555
559
|
|
|
560
|
+
# capability_effect_probe — wired 2026-08-16. `lane_runner_check.sh` had been flagging it as an
|
|
561
|
+
# embedded --self-test subject with ZERO dispatchers, and that warning was load-bearing: the same
|
|
562
|
+
# day, two SECURITY regression anchors were added to this suite (L8 args injection · L9 the git
|
|
563
|
+
# metadata channel that let a `read-only` declaration plant a persistent `core.pager` hook in the
|
|
564
|
+
# real repo while the probe printed VERIFIED). Unwired, those anchors would never have run anywhere
|
|
565
|
+
# but on the author's machine — the exact "built but not wired" shape this repo keeps reproducing.
|
|
566
|
+
# Not in the `_subj` loop above: its terminal line is `N PASS / M FAIL`, never 캘리브레이션, so that
|
|
567
|
+
# loop's substring gate would go red for a reason unrelated to its lanes (capability_registry_check
|
|
568
|
+
# was added there and reverted for precisely this). Direct dispatch instead.
|
|
569
|
+
# 🟥 Exit code ALONE is not enough — a suite whose lanes were all deleted still exits 0 (measured
|
|
570
|
+
# 2026-08-15 on directional_diff_gate). Require the terminal verdict AS A WHOLE LINE with a
|
|
571
|
+
# non-zero PASS count, so an emptied suite cannot certify itself.
|
|
572
|
+
if [ ! -f scripts/capability_effect_probe.sh ]; then
|
|
573
|
+
echo "FAIL capability_effect_probe.sh: missing — it ships via package.json files[], so absence is deletion, not package mode"
|
|
574
|
+
fail=1
|
|
575
|
+
elif _out=$(bash scripts/capability_effect_probe.sh --self-test < /dev/null 2>&1) \
|
|
576
|
+
&& printf '%s\n' "$_out" | grep -qE '^── capability_effect_probe lanes: [1-9][0-9]* PASS / 0 FAIL ──$'; then
|
|
577
|
+
echo "PASS capability_effect_probe.sh --self-test ($(printf '%s\n' "$_out" | grep -oE '[0-9]+ PASS / [0-9]+ FAIL' | tail -1))"
|
|
578
|
+
else
|
|
579
|
+
echo "FAIL capability_effect_probe.sh: --self-test failed or produced no terminal verdict line"
|
|
580
|
+
_show_failure "$_out"
|
|
581
|
+
fail=1
|
|
582
|
+
fi
|
|
583
|
+
|
|
556
584
|
# relay_channel — the other embedded --self-test subject kept out of the loop above: its
|
|
557
585
|
# `--self-test` just `exec`s the sibling scripts/test_relay_channel_lanes.sh, whose own exit code
|
|
558
586
|
# is already disciplined (`[ "$fail" -eq 0 ] || exit 1`), so gating on exit code — same shape as
|
|
@@ -53,6 +53,16 @@ fi
|
|
|
53
53
|
FILES_JSON=$(node -e 'process.stdout.write(JSON.stringify(require("./package.json").files||[]))' 2>/dev/null) || {
|
|
54
54
|
echo " INSTRUMENT ERROR: could not read package.json files[]"; exit 1; }
|
|
55
55
|
|
|
56
|
+
# ★`mktemp` — 초판은 `${TMPDIR:-/tmp}/cap_entry_$$.txt` 였다. TMPDIR 없는 환경(리눅스 CI·
|
|
57
|
+
# 컨테이너가 흔하다)에서 공용 `/tmp` + PID 기반 **예측 가능한 이름**이라, 미리 심어둔 심링크를
|
|
58
|
+
# `>` 리다이렉트가 따라가 대상 파일을 덮어쓴다(CWE-377, 보안 패스 [B]). 이 파일은 selfcheck 에
|
|
59
|
+
# 배선돼 **소비자 머신에서 `npm test` 로 돈다**.
|
|
60
|
+
# ⚠️ `mktemp -t <prefix>` 는 **BSD 전용**이다 — GNU 는 템플릿에 X 를 요구하고
|
|
61
|
+
# `mktemp: too few X's in template` 로 죽는다(2026-08-16 CI 실측: 로컬 macOS 초록 · ubuntu 적색).
|
|
62
|
+
# 이 레포가 이미 기록한 `stat -f` BSD-first 와 같은 얼굴이다. 양쪽에서 도는 형태는
|
|
63
|
+
# **명시 템플릿 + X 다수**뿐이다. (계기가 fail-closed 로 막아 준 덕에 조용히 안 지나갔다.)
|
|
64
|
+
_CAP_TMP=$(mktemp "${TMPDIR:-/tmp}/cap_entry.XXXXXXXX") || { echo " INSTRUMENT ERROR: mktemp 실패"; exit 1; }
|
|
65
|
+
trap 'rm -f "$_CAP_TMP"' EXIT INT TERM
|
|
56
66
|
printf '%s\n' "$ENTRIES" | while IFS= read -r e; do
|
|
57
67
|
[ -n "$e" ] || continue
|
|
58
68
|
if node -e 'const f=JSON.parse(process.argv[1]);process.exit(f.includes(process.argv[2])?0:1)' "$FILES_JSON" "$e"; then
|
|
@@ -60,14 +70,14 @@ printf '%s\n' "$ENTRIES" | while IFS= read -r e; do
|
|
|
60
70
|
else
|
|
61
71
|
echo " MISSING $e"
|
|
62
72
|
fi
|
|
63
|
-
done > "$
|
|
73
|
+
done > "$_CAP_TMP"
|
|
64
74
|
|
|
65
|
-
missing=$(grep -c '^ MISSING ' "$
|
|
75
|
+
missing=$(grep -c '^ MISSING ' "$_CAP_TMP" || true)
|
|
66
76
|
missing=$(( ${missing:-0} + 0 ))
|
|
67
|
-
shipped=$(grep -c '^ ok ' "$
|
|
77
|
+
shipped=$(grep -c '^ ok ' "$_CAP_TMP" || true)
|
|
68
78
|
shipped=$(( ${shipped:-0} + 0 ))
|
|
69
|
-
cat "$
|
|
70
|
-
rm -f "$
|
|
79
|
+
cat "$_CAP_TMP"
|
|
80
|
+
rm -f "$_CAP_TMP"
|
|
71
81
|
|
|
72
82
|
if [ "$missing" -eq 0 ]; then
|
|
73
83
|
ok "all $shipped capability entry point(s) are in package.json files[]"
|
|
@@ -118,6 +128,66 @@ else
|
|
|
118
128
|
bad "$dangling declared capability entry point(s) missing from disk — npm would ship a broken files[] and the disk-side check alone stays green"
|
|
119
129
|
fi
|
|
120
130
|
|
|
131
|
+
# SIBLING DEPENDENCIES (added 2026-08-16 — occurrence #4 of the class this file's header names).
|
|
132
|
+
# The two directions above both key on the ENTRY-POINT convention (`*_capability.sh`). That is
|
|
133
|
+
# structurally blind to the failure that actually happened this time: a shipped script called a
|
|
134
|
+
# NON-entry-point sibling that did not ship. `capability_registry_check.sh` shipped, its new M6 axis
|
|
135
|
+
# ran `${0%/*}/capability_effect_probe.sh`, and that probe was absent from files[] — so on a consumer
|
|
136
|
+
# install M6 would hit its fail-closed branch and reject every registration, blaming a "missing probe"
|
|
137
|
+
# that exists in the repo.
|
|
138
|
+
#
|
|
139
|
+
# WHY `package_coverage_check.sh` IS BLIND TO IT — and why the fix belongs here, not there: that
|
|
140
|
+
# checker's extraction regex requires a reference to START with a shipped top-level directory
|
|
141
|
+
# (`scripts|templates|bin|docs|knowledge|plugins|.claude`). A script naming its own sibling writes
|
|
142
|
+
# `${0%/*}/name.sh` or `$(dirname "$0")/name.sh` — the path is BUILT AT RUNTIME and no literal
|
|
143
|
+
# `scripts/` appears. The reference-follower cannot see a path that does not textually exist.
|
|
144
|
+
#
|
|
145
|
+
# Scope kept narrow on purpose: only SELF-DIRECTORY forms. `$REPO_ROOT/scripts/x.sh` already contains
|
|
146
|
+
# the literal `scripts/x.sh` and is caught upstream; widening past that would duplicate that checker.
|
|
147
|
+
SIB_PAT='(\$\{0%/\*\}|\$\(dirname [^)]*\)|\$\{BASH_SOURCE[^}]*%/\*\})/[A-Za-z0-9_.-]+\.(sh|py|js)'
|
|
148
|
+
SHIPPED_SH=$(node -e '
|
|
149
|
+
const f=JSON.parse(process.argv[1]);
|
|
150
|
+
process.stdout.write(f.filter(p=>/^scripts\/.*\.sh$/.test(p)).join("\n"));
|
|
151
|
+
' "$FILES_JSON")
|
|
152
|
+
|
|
153
|
+
sib_refs=0; sib_missing=0
|
|
154
|
+
if [ -n "$SHIPPED_SH" ]; then
|
|
155
|
+
while IFS= read -r s; do
|
|
156
|
+
[ -n "$s" ] && [ -f "$s" ] || continue
|
|
157
|
+
# `grep -o` yields the whole matched expression; the sibling NAME is its last path segment.
|
|
158
|
+
# COMMENT LINES ARE STRIPPED FIRST, and that is not cosmetic: the finding's text asserts the
|
|
159
|
+
# callee is "called at runtime". A file whose PROSE names a sibling (this file's own header does)
|
|
160
|
+
# would be reported under a claim that is false about it — the right defect attributed to the
|
|
161
|
+
# wrong caller. Caught during this lane's own known-pair calibration, where the positive arm
|
|
162
|
+
# named two callers and only one of them actually calls anything.
|
|
163
|
+
for ref in $(sed 's/^[[:space:]]*#.*//' "$s" | grep -oE "$SIB_PAT" 2>/dev/null | sed 's|.*/||' | sort -u); do
|
|
164
|
+
sib_refs=$((sib_refs+1))
|
|
165
|
+
# Only assert on siblings that EXIST in the repo. A reference to a name that is absent from
|
|
166
|
+
# disk too is a different defect (a dead call), and it belongs to the dangling check above —
|
|
167
|
+
# claiming it here would report one defect as two.
|
|
168
|
+
[ -f "scripts/$ref" ] || continue
|
|
169
|
+
if node -e 'const f=JSON.parse(process.argv[1]);process.exit(f.includes(process.argv[2])?0:1)' \
|
|
170
|
+
"$FILES_JSON" "scripts/$ref"; then :; else
|
|
171
|
+
echo " SIBLING-MISSING scripts/$ref (called by $s at runtime, absent from files[])"
|
|
172
|
+
sib_missing=$((sib_missing+1))
|
|
173
|
+
fi
|
|
174
|
+
done
|
|
175
|
+
done <<EOF
|
|
176
|
+
$SHIPPED_SH
|
|
177
|
+
EOF
|
|
178
|
+
fi
|
|
179
|
+
|
|
180
|
+
# Discovery-zero is an instrument failure here for the same reason as above: this repo is KNOWN to
|
|
181
|
+
# contain at least one self-directory sibling call (that is why this block exists). Zero means the
|
|
182
|
+
# pattern stopped matching — a silent scanner, not a clean repo.
|
|
183
|
+
if [ "$sib_refs" -eq 0 ]; then
|
|
184
|
+
bad "INSTRUMENT DEAD — zero self-directory sibling references found across $(printf '%s\n' "$SHIPPED_SH" | grep -c .) shipped scripts; the pattern is no longer matching anything"
|
|
185
|
+
elif [ "$sib_missing" -eq 0 ]; then
|
|
186
|
+
ok "all $sib_refs runtime sibling dependenc(ies) of shipped scripts are themselves in files[]"
|
|
187
|
+
else
|
|
188
|
+
bad "$sib_missing runtime sibling dependenc(ies) absent from files[] — the consumer gets a caller whose callee is missing"
|
|
189
|
+
fi
|
|
190
|
+
|
|
121
191
|
# CONTROL — the check must be able to say NO. A checker that only ever prints ok is indistinguishable
|
|
122
192
|
# from a checker that is not looking; assert the negative arm on a name that cannot be in files[].
|
|
123
193
|
if node -e 'const f=JSON.parse(process.argv[1]);process.exit(f.includes(process.argv[2])?0:1)' \
|