@chrono-meta/fh-gate 1.4.93 → 1.4.95

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.
@@ -94,7 +94,11 @@ The four constituent skills use heterogeneous scope models. Translate the pipeli
94
94
 
95
95
  ## Step 0.5. return-path-gate — Pre-flight Chain Audit
96
96
 
97
- > Skip if return-path-gate is not installed or scope is a single non-pipeline skill.
97
+ > Skip only when scope is a single non-pipeline skill. **return-path-gate not installed is a recorded
98
+ > degrade, never a free skip**: proceed, but record `degraded: return-path-gate (not installed)` in the
99
+ > final report — and, symmetric with the user-override row below, the sweep **cannot reach
100
+ > `CLEAN (--full)`**. A missing tool must not pass more cheaply than an explicit refusal (that
101
+ > asymmetry trains uninstalling the gate).
98
102
 
99
103
  Run `/return-path-gate --skill [scope]`.
100
104
 
@@ -197,7 +201,8 @@ After all steps complete (or after chain halt), output the aggregated report (te
197
201
 
198
202
  | Condition | Overall |
199
203
  |---|---|
200
- | All steps PASS | `CLEAN ({mode})` |
204
+ | All steps PASS, zero `degraded:` entries | `CLEAN ({mode})` |
205
+ | All steps PASS but 1+ `degraded:` entries (skip/not-installed/override) | `CLEAN ({mode}, degraded: [list])` — and the mode may not render `--full` (the Step 0.5 "cannot reach CLEAN (--full)" rule binds HERE, in the aggregate, not only in prose) |
201
206
  | Any step CONDITIONAL_PASS or accepted ESCALATE; none FAIL | `PENDING` |
202
207
  | Any step FAIL or unresolved ESCALATE (option c) | `BLOCKED` |
203
208
 
@@ -263,7 +268,7 @@ complexity_routing:
263
268
 
264
269
  ```
265
270
  Step 0 scope confirmed and scope translation table applied
266
- + Step 0.5 return-path-gate pre-flight: PASS / CONDITIONAL_PASS / FAIL (halts sweep) / explicitly skipped / degraded (user override)
271
+ + Step 0.5 return-path-gate pre-flight: PASS / CONDITIONAL_PASS / FAIL (halts sweep) / explicitly skipped / degraded (user override | not installed)
267
272
  + All in-scope steps executed and verdicts emitted
268
273
  + Aggregated report output (Step 5 format)
269
274
  + Report saved to tracks/_meta/ (or skip warning issued)
@@ -273,6 +278,11 @@ Step 0 scope confirmed and scope translation table applied
273
278
  + If ESCALATE occurred: user presented three options; choice recorded in report
274
279
  ```
275
280
 
281
+ **Check classes**: every fence row above is *mandatory-pass*, except the completion-claim discipline
282
+ below, which is *measured* — its reconciliation (failed+skipped list length vs. in-scope − passed) is
283
+ the mechanical anchor. No Done When row here is judged: verdict aggregation is per-step-typed, not
284
+ discretionary.
285
+
276
286
  Verdict: PASS (all conditions met, sweep complete) | CONDITIONAL_PASS (sweep complete, pending items captured) | FAIL (chain halted, blocking items remain) | ESCALATE (chain paused, human decision required)
277
287
 
278
288
  A sweep is not done until the Step 5 report is output. Emitting per-step verdicts without the aggregated report is incomplete.
@@ -38,7 +38,9 @@ deliberately excluded — `CLAUDE.local.md`, the companion store, and local sess
38
38
  *correct* home for operator-private tokens, so finding them there is not a leak.
39
39
 
40
40
  ```bash
41
- REPO_PATH="${ARGUMENTS#--target }"
41
+ ARGS="${ARGUMENTS//--json/}" # strip flags first — "--json" alone must not become the path
42
+ REPO_PATH="${ARGS#--target }"
43
+ REPO_PATH="$(printf '%s' "$REPO_PATH" | xargs)"
42
44
  REPO_PATH="${REPO_PATH:-$(pwd)}"
43
45
  git -C "$REPO_PATH" rev-parse --is-inside-work-tree >/dev/null 2>&1 \
44
46
  || { echo "Not a git repo — public-surface-audit scans git-tracked files only. Aborting."; exit 1; }
@@ -50,12 +52,18 @@ git -C "$REPO_PATH" ls-files | wc -l | xargs echo "Tracked files:"
50
52
 
51
53
  ## Step 1. Pattern List (configurable)
52
54
 
53
- The patterns **are themselves operator-private** your real username and employer name must not be
54
- hardcoded *here*, on the public surface, or this skill would leak exactly what it hunts. So the literal
55
- values live in a **gitignored source you supply** (`.claude/rules/.public-surface-patterns`, or a
56
- section of `CLAUDE.local.md`) — one `severity<TAB>regex` per line. This SKILL.md carries only
57
- placeholders; the scan reads the gitignored file, never literals from this table. The skill dogfoods
58
- its own rule.
55
+ The pattern source is **two layers, matching the mechanical scan layer** (`scripts/psa_scan_lib.sh`,
56
+ shared with the pre-commit/pre-push/pre-publish hooks this skill must render the same verdict the
57
+ hooks would, or the same repo state gets two answers):
58
+
59
+ 1. **`.claude/rules/.public-surface-patterns.defaults`** committed, universal placeholder-grade
60
+ patterns (safe to publish; carries no operator literal).
61
+ 2. **`.claude/rules/.public-surface-patterns`** — **gitignored, operator literals** (or `PSA_PATTERNS`
62
+ env override). Your real username and employer name live *only* here — hardcoding them in a tracked
63
+ file would leak exactly what this skill hunts. One `severity<TAB>regex` per line.
64
+
65
+ The scan reads the **union of both layers**. This SKILL.md carries only placeholders; the skill
66
+ dogfoods its own rule.
59
67
 
60
68
  | # | Token class | Severity | Placeholder (real value goes in the gitignored source) | Why private |
61
69
  |:-:|---|:-:|---|---|
@@ -71,12 +79,16 @@ its own rule.
71
79
  - **LOW** — companion-store / private-wiring name. Methodology should be model-agnostic; naming a private
72
80
  store is drift, not a confidentiality breach.
73
81
 
74
- > **Setup**: put your real values in the gitignored pattern source (one `severity<TAB>regex` per line);
75
- > the scan reads that file, never literals from this SKILL.md. If the source is **absent**, the scan
76
- > reports **NOT CONFIGURED** — *not* CLEAN. A missing pattern file must never masquerade as a clean bill
77
- > of health (that would be a silent failure: "nothing scanned" misread as "nothing leaked"). To declare
78
- > "I genuinely have no private tokens", create the file **empty** an empty file is an explicit CLEAN,
79
- > an absent file is unconfigured.
82
+ > **Setup**: put your real values in the gitignored override (one `severity<TAB>regex` per line).
83
+ > Degrade states, by layer never collapse them:
84
+ > - **Neither layer present** → **NOT CONFIGURED** — *not* CLEAN. A missing pattern source must never
85
+ > masquerade as a clean bill of health ("nothing scanned" misread as "nothing leaked").
86
+ > - **Defaults only** (override absent) → scan runs, but the verdict carries the label
87
+ > `operator literals NOT CONFIGURED universal patterns only; identity/company classes UNSCANNED`.
88
+ > This mirrors what the hooks scan in the same state, instead of reporting NOT CONFIGURED while the
89
+ > hook path quietly scans defaults (two verdicts for one repo state — the divergence this line closes).
90
+ > - To declare "I genuinely have no private tokens", create the override **empty** — an empty file is
91
+ > an explicit declaration, an absent file is unconfigured.
80
92
 
81
93
  ---
82
94
 
@@ -86,6 +98,11 @@ Some tracked files legitimately reference otherwise-private tokens — the scan
86
98
  leaks. Maintain an allowlist of `file path :: token` pairs. A match is suppressed only when **both**
87
99
  the file and the token are on the allowlist row.
88
100
 
101
+ **Where the filled allowlist lives**: a populated allowlist row *names private tokens*, so it belongs
102
+ in a **gitignored** source beside the pattern override (`.claude/rules/.public-surface-allowlist`, or a
103
+ section of `CLAUDE.local.md`) — never in a tracked file. The table below shows generic shapes only; a
104
+ tracked copy of your real allowlist would itself be the leak this skill scans for.
105
+
89
106
  | Tracked file | Allowed tokens | Reason |
90
107
  |---|---|---|
91
108
  | `.gitignore` | companion-store name, sync-script name | Must name what it ignores |
@@ -172,11 +189,17 @@ class of leak). Diagnostic-only: this step never writes — it reports, the oper
172
189
  Report per-hit `file:line → matched token [class]` grouped by severity, then the overall verdict.
173
190
  **Verdict thresholds** (behavioral — these define the gate):
174
191
 
175
- - ⚪ **NOT CONFIGURED** — pattern source absent (nothing scanned — NOT a clean result; set up first)
176
- - 🟢 **CLEAN** — pattern source present (incl. empty), 0 HIGH + 0 MED + 0 LOW (after allowlist)
192
+ - ⚪ **NOT CONFIGURED** — no pattern layer present (nothing scanned — NOT a clean result; set up first)
193
+ - 🟢 **CLEAN** — pattern source present (incl. empty override), 0 HIGH + 0 MED + 0 LOW (after allowlist)
177
194
  - 🟡 **REVIEW** — 0 HIGH + 0 MED, LOW-only (drift, not a breach)
178
195
  - 🔴 **LEAK** — 1+ HIGH or 1+ MED (block publish / fix before commit)
179
196
 
197
+ **Every verdict line carries a `coverage:` qualifier** — `full` (both layers loaded) or
198
+ `defaults-only` (override absent: operator identity/company classes UNSCANNED). The qualifier exists
199
+ because the enum alone cannot preserve the defaults-only state: `CLEAN (coverage: defaults-only)` on
200
+ an operator-configured checkout is missing evidence, not a pass — publish-gating callers there must
201
+ require `coverage: full`.
202
+
180
203
  > **Detail**: See `SKILL_detail.md §Report-Template` — the full human-report ASCII layout (severity
181
204
  > buckets, allowlist-suppressed line) — read when formatting the Step 4 report.
182
205
 
@@ -207,7 +230,9 @@ enforceable (FH's "enforcement is a hook, not a prompt" principle). Imported fro
207
230
  authoritative — never gate on `counts` alone**: a counts-only check (`HIGH==0 && MED==0`) misreads
208
231
  `NOT_CONFIGURED` (which also has zero counts) as a pass. A caller blocks when `verdict` is `LEAK` **or**
209
232
  `NOT_CONFIGURED` — an unconfigured scan is not a pass (the same silent-failure guard as the human path:
210
- absence ≠ CLEAN).
233
+ absence ≠ CLEAN). The JSON carries the same `coverage` field as Step 4; on an operator-configured
234
+ checkout a publish-gating caller additionally blocks on `coverage: "defaults-only"` (identity/company
235
+ classes unscanned there — zero counts prove nothing about the classes that never ran).
211
236
 
212
237
  ---
213
238
 
@@ -236,15 +261,24 @@ Usable standalone — no hub clone required.
236
261
  ## Done When
237
262
 
238
263
  ```
239
- Step 1 pattern list confirmed (defaults shown / user-adapted)
240
- + Step 2 allowlist applied
241
- + Step 3 scan run for every pattern over git ls-files (tracked only gitignored excluded)
242
- + Step 4 report output: per-hit file:line + token + severity, plus overall verdict
243
- + "public-surface-audit Complete" declaration output
264
+ Step 1 pattern source resolved (both layers checked; absent/partial
265
+ states rendered as their labeled degrade state, never as CLEAN) — mandatory-pass
266
+ + Step 2 allowlist applied (from a gitignored source) — mandatory-pass
267
+ + Step 3 scan run for every pattern over git ls-files (tracked only
268
+ gitignored excluded), malformed pattern rows counted and reported,
269
+ never silently dropped — measured (hit + malformed counts)
270
+ + Step 3b/3c run (FP-hygiene on matched tokens; ignore-verification
271
+ for expected-private paths) or explicitly skipped with reason — mandatory-pass
272
+ + Step 4 report output: per-hit file:line + token + severity, plus
273
+ overall verdict — mandatory-pass
274
+ + "public-surface-audit Complete" declaration output — mandatory-pass
244
275
  ```
245
276
 
246
277
  Verdict: **CLEAN** (0 tokens after allowlist) | **REVIEW** (LOW-only — drift, prescriptions noted) |
247
- **LEAK** (1+ HIGH or 1+ MED — block publish, prescriptions attached).
278
+ **LEAK** (1+ HIGH or 1+ MED — block publish, prescriptions attached) | **NOT_CONFIGURED** (no pattern
279
+ source — nothing scanned, not a pass; same 4-value enum as Step 5 `--json`. An earlier version of this
280
+ enum had 3 values here, so a session following only Done When would force absent-config into CLEAN/
281
+ REVIEW/LEAK — re-opening the exact silent-pass the body closes).
248
282
 
249
283
  ---
250
284
 
@@ -11,19 +11,58 @@ cd "$REPO_PATH" || exit 1
11
11
  # Build the tracked-file list once.
12
12
  git ls-files > /tmp/_psa_tracked.txt
13
13
 
14
- # Load your real patterns from the gitignored source (one "severity<TAB>regex" per line).
15
- PATTERN_SRC="${PSA_PATTERNS:-.claude/rules/.public-surface-patterns}"
16
- # Absent file CLEAN. An absent file is unconfigured (silent-failure risk); an EMPTY file is an
17
- # explicit "no tokens to protect" → CLEAN. Distinguish the two.
18
- [ -e "$PATTERN_SRC" ] || { echo "⚪ NOT CONFIGURED: no pattern source at $PATTERN_SRC. Create it (empty = explicit CLEAN) before trusting any verdict. Not scanning."; exit 2; }
19
-
20
- # One grep pass per pattern row; the regex comes from the file, never hardcoded here.
21
- while IFS=$'\t' read -r severity regex; do
22
- [ -z "$regex" ] && continue
23
- grep -nIE "$regex" $(cat /tmp/_psa_tracked.txt) 2>/dev/null | sed "s/^/[$severity] /"
24
- done < "$PATTERN_SRC"
14
+ # ── Single-source preference: when the shared library exists, use it and write NO second loop ──
15
+ # scripts/psa_scan_lib.sh owns loading + row validation + exemptions for the hook layer; a hand-rolled
16
+ # copy here is a second normalizer with its own leniency (the divergence class this rewrite removed).
17
+ if [ -r "scripts/psa_scan_lib.sh" ]; then
18
+ . scripts/psa_scan_lib.sh
19
+ psa_load ".claude/rules/.public-surface-patterns.defaults" \
20
+ "${PSA_PATTERNS:-.claude/rules/.public-surface-patterns}"
21
+ { [ "$PSA_DEFAULTS_OK" -eq 1 ] || [ "$PSA_OVERRIDE_PRESENT" -eq 1 ]; } \
22
+ || { echo "⚪ NOT CONFIGURED: neither pattern layer present. Not scanning."; exit 2; }
23
+ [ "$PSA_BAD_ROWS" -gt 0 ] \
24
+ && { echo "HARNESS_ERROR: $PSA_BAD_ROWS unusable pattern row(s) — verdict cannot be CLEAN"; exit 10; }
25
+ # Feed every tracked file as path<TAB>line, the stream psa_scan_tagged consumes. Sourcing the lib
26
+ # without these calls is a no-op scan — measured on this repo (PSA_STREAM stayed unset), so the
27
+ # calls are spelled out here rather than pointed at.
28
+ while IFS= read -r f; do
29
+ awk -v p="$f" '{printf "%s\t%s\n", p, $0}' "$f" 2>/dev/null
30
+ done < /tmp/_psa_tracked.txt | psa_scan_tagged
31
+ [ "$PSA_OVERRIDE_PRESENT" -eq 1 ] \
32
+ || echo "coverage: defaults-only — operator literals NOT CONFIGURED (identity/company classes UNSCANNED)"
33
+ else
34
+ # ── Standalone fallback (no hub scripts in this repo) — validated loop, malformed rows COUNTED ──
35
+ PSA_DEFAULTS=".claude/rules/.public-surface-patterns.defaults"
36
+ PATTERN_SRC="${PSA_PATTERNS:-.claude/rules/.public-surface-patterns}"
37
+ SRC_LIST=""
38
+ [ -e "$PSA_DEFAULTS" ] && SRC_LIST="$PSA_DEFAULTS"
39
+ [ -e "$PATTERN_SRC" ] && SRC_LIST="$SRC_LIST $PATTERN_SRC"
40
+ [ -n "$SRC_LIST" ] || { echo "⚪ NOT CONFIGURED: no pattern source (neither defaults nor override). Not scanning."; exit 2; }
41
+
42
+ MALFORMED=0
43
+ cat $SRC_LIST > /tmp/_psa_rows.txt # no pipe into the loop — a piped while runs in a subshell and loses MALFORMED
44
+ # `|| [ -n "$severity" ]` keeps a final row that lacks a trailing newline — `read` alone drops it silently.
45
+ while IFS=$'\t' read -r severity regex || [ -n "$severity" ]; do
46
+ case "$severity" in ''|'#'*) continue ;; esac # blank / comment rows
47
+ if [ -z "$regex" ]; then # no tab separator → malformed, VISIBLE
48
+ MALFORMED=$((MALFORMED+1)); echo "MALFORMED ROW (no <TAB>): $severity" >&2; continue
49
+ fi
50
+ printf 'x\n' | grep -qE "$regex" 2>/dev/null
51
+ rc=$? # plain rc capture — `if !` would negate $?
52
+ if [ "$rc" -ge 2 ]; then # grep rc≥2 = invalid regex, not "no match"
53
+ MALFORMED=$((MALFORMED+1)); echo "MALFORMED ROW (bad regex): $regex" >&2; continue
54
+ fi
55
+ grep -nIE "$regex" $(cat /tmp/_psa_tracked.txt) | sed "s/^/[$severity] /"
56
+ done < /tmp/_psa_rows.txt
57
+ # Malformed rows poison the verdict: part of the pattern file never scanned → CLEAN is unprovable.
58
+ [ "$MALFORMED" -gt 0 ] && { echo "HARNESS_ERROR: $MALFORMED malformed pattern row(s) — verdict cannot be CLEAN"; exit 10; }
59
+ fi
25
60
  ```
26
61
 
62
+ **Why the fallback validates instead of skipping**: the previous loop dropped a malformed row with a
63
+ bare `continue` and discarded grep's stderr — a broken pattern file scanned "clean" by silently not
64
+ scanning. `not found ≠ 0`: a row that never ran is not a row with zero hits.
65
+
27
66
  For each pattern, run `grep -nIE "<regex>" $(git ls-files)`:
28
67
  - `-n` → line numbers (required for `file:line` output)
29
68
  - `-I` → skip binary files
@@ -66,7 +66,27 @@ find plugins/ -name "SKILL.md" | sort
66
66
  find plugins/ -path "*[name]/SKILL.md"
67
67
  ```
68
68
 
69
- If 0 SKILL.md files are in scope, output: "No SKILL.md files in scopeaudit skipped." and stop.
69
+ If 0 SKILL.md files are in scope, **distinguish the two causes before stopping**a failed scope
70
+ command and a genuinely empty scope both render as zero lines, and only one of them is skippable:
71
+
72
+ ```bash
73
+ git rev-parse --git-dir >/dev/null 2>&1 \
74
+ || { echo "HARNESS_ERROR: git unavailable — scope could NOT be measured (not an empty scope)"; exit 10; }
75
+
76
+ # Repo-liveness alone is NOT enough: a broken ref (e.g. missing origin/main in --pr mode) fails the
77
+ # scope command with rc=1 and empty output while rev-parse stays green. Capture the scope command's
78
+ # OWN exit status — before the grep, which legitimately returns 1 on no-match:
79
+ scope_out=$(git diff --name-only origin/main...HEAD 2>&1); scope_rc=$?
80
+ [ "$scope_rc" -ne 0 ] \
81
+ && { echo "HARNESS_ERROR: scope command failed (rc=$scope_rc): $scope_out"; exit 10; }
82
+ printf '%s\n' "$scope_out" | grep "SKILL\.md"
83
+ # (same rc-capture shape applies to the default and --all scope commands)
84
+ ```
85
+
86
+ With both controls green and genuinely 0 files in scope, output:
87
+ "No SKILL.md files in scope — audit skipped (control: git rev-parse OK)." and stop.
88
+ A HARNESS_ERROR is a distinct non-pass — it must never be reported as "audit skipped", because this
89
+ skill is pipeline-conductor's Step 0.5 pre-flight and a tooling-down skip would render as a pass there.
70
90
 
71
91
  ---
72
92
 
@@ -221,11 +241,19 @@ Next actions:
221
241
  ## Done When
222
242
 
223
243
  ```
224
- Step 0 scope determined; SKILL.md files identified
225
- + Step 1 two-pass extraction complete (Pass A caller-wait + Pass B callee-output evaluated separately)
226
- + Step 2 every (caller callee) pair classified CLOSED or OPEN with two-dimension severity
227
- + Step 3 fix prescriptions output for each OPEN chain (core FH skills: proposal-only)
228
- + Step 4 summary report with CLOSED/OPEN counts output
244
+ Step 0 scope determined; a failed scope command reported as
245
+ HARNESS_ERROR, never as empty scope — mandatory-pass
246
+ + Step 1 two-pass extraction complete (Pass A caller-wait +
247
+ Pass B callee-output evaluated separately) — mandatory-pass
248
+ + Step 2 every (caller callee) pair classified CLOSED or
249
+ OPEN with two-dimension severity — judged (adversarial pairing:
250
+ before ANY chain is reported OPEN, the ground-truth CLOSED
251
+ pair from §Operating Notes must classify CLOSED in the same
252
+ run — an instrument that cannot separate the known pair
253
+ reports nothing)
254
+ + Step 3 fix prescriptions output for each OPEN chain
255
+ (core FH skills: proposal-only) — mandatory-pass
256
+ + Step 4 summary report with CLOSED/OPEN counts output — measured (counts)
229
257
  ```
230
258
 
231
259
  > Fix prescriptions are text output only (Write not in allowed-tools). Prescription application is manual and out of scope for this skill. Verification of applied fixes requires re-running `/return-path-gate --skill [name]`.
@@ -254,4 +282,5 @@ Verdict: PASS (0 HIGH severity OPEN chains) | CONDITIONAL_PASS (MEDIUM/LOW sever
254
282
  - **CONDITIONAL_PASS gate is the highest-risk gap**: A chain where CONDITIONAL_PASS conditions are listed by the callee but the caller has no enforcement path is OPEN even if other verdict paths fold correctly.
255
283
  - **Scope default is narrow by design**: Default captures modified + newly added files (not just staged changes). Use `--pr` for PR-relative mode in worktrees, `--all` for periodic sweeps.
256
284
  - **Core FH skill prescriptions are proposals only**: Edits to harvest-loop, steel-quench, sim-conductor, and other core skills require deliberate review — output proposal notes, not standard prescriptions.
257
- - **Reference pattern**: `knowledge/shared/harness-core/return_path_gate.md` defines the canonical closed-loop structure and verified instances (apex-review → sim-conductor, agent-composer ↔ deliberation). These are the ground-truth CLOSED examples for calibrating classification.
285
+ - **Reference pattern**: `knowledge/shared/harness-core/return_path_gate.md` defines the canonical closed-loop structure and verified instances (apex-review → sim-conductor, agent-composer ↔ deliberation). These are the ground-truth CLOSED examples for calibrating classification — and that calibration is a Done When condition (Step 2's adversarial pairing), not an optional note.
286
+ - **The Pass A signal list is a closed English keyword list — know its degrade direction**: synonyms ("blocks until", "halts on") and non-English documentation will read as false OPEN. That direction is by design (over-report, never silent-close), but treat an OPEN verdict on unconventional wording as a candidate for human reading before prescribing a fix.
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env bash
2
+ # capability_registry_check.sh — 정체성 ① 의 «등록 시점» 검사기.
3
+ #
4
+ # ─────────────────────────────────────────────────────────────────────────────
5
+ # 왜 이게 지어졌나 — 스펙이 이름까지 붙여놓고 «없다» 고 적어둔 파일
6
+ # ─────────────────────────────────────────────────────────────────────────────
7
+ # `capability_composition_contract.md §Salience` 는 이렇게 적는다:
8
+ #
9
+ # "Named residual, not built: a scripts/capability_registry_check.sh that validates
10
+ # the schema and runs each declared M4 pair would make registration *measured*
11
+ # rather than reviewed. It does not exist."
12
+ #
13
+ # `relay_channel.sh` 헤더도 같은 말을 한다 — 자기는 **call moment** 만 보고, 선언이
14
+ # **진실인지**는 검사하지 않는다고. 이 파일이 그 나머지 절반이다.
15
+ #
16
+ # ─────────────────────────────────────────────────────────────────────────────
17
+ # 이 검사기가 증명하지 않는 것 (과잉주장 금지 — 명명된 잔여)
18
+ # ─────────────────────────────────────────────────────────────────────────────
19
+ # · **등록을 강제하지 않는다.** "지금 capability 를 등록하려 한다"를 관측하는 훅은 없다.
20
+ # 돌리면 판정하지만 돌리도록 강제하지는 못한다(relay_channel.sh 의 같은 잔여와 동형).
21
+ # · **M3(모델 독립성)은 선언 검사다.** reps 로 재지 않는다 — `judge:` 축 선언과, mechanical
22
+ # 선언인데 entry 가 LLM CLI 를 부르는 명백한 모순만 잡는다. M4 를 reps=2 로 돌려 부분 방어.
23
+ # · 🟥 **`writes:` 축은 검증 불가 — 그리고 그게 이 파일에서 실제로 터졌다.**
24
+ # 2026-08-11, 이 검사기를 통과한 capability(`writes: read-only` 선언)의 진입점이
25
+ # 정리 트랩 결함으로 **레포의 `scripts/` 를 rm -rf 했다.** M1(실행 가능)·M2(닫힌 enum)·
26
+ # M3(mechanical)·M4(known-pair 통과)·M5(cwd) 를 **전부 통과한 채로** 그랬다.
27
+ # 등록 바는 «선언이 형식에 맞나 · 답 아는 쌍을 가르나» 를 보지, **«선언이 사실인가»**
28
+ # 를 보지 않는다. read-only 선언의 진위는 여기서 닫히지 않는다.
29
+ # 부분 처방(오늘 적용): 진입점 쪽 트랩 규율(정리 대상 변수 재대입 금지 + 임시경로 검문).
30
+ # 구조 처방(미구축): 샌드박스/읽기전용 마운트에서 M4 를 돌려 쓰기 시도를 관측하는 것.
31
+ # 그 전까지 `writes: read-only` 는 **등록자 주장**이지 이 검사기의 판정이 아니다.
32
+ #
33
+ # 사용법
34
+ # capability_registry_check.sh <capfile> [<capfile> ...]
35
+ # capability_registry_check.sh --self-test
36
+ #
37
+ # exit code
38
+ # 0 REGISTRABLE 전 capfile 이 M1–M5 + 추가조항 통과
39
+ # 1 REJECTED 하나 이상 기준 미달 (등록 불가 — 그 표면은 dispatch 로 남는다)
40
+ # 10 HARNESS_ERROR capfile 도달 불가·파손·검사기 자신의 전제 파손
41
+ #
42
+ set -o pipefail
43
+ set -f # noglob — 선언 파일의 값은 데이터지 파일 패턴이 아니다 (relay_channel.sh 와 동일)
44
+
45
+ RC_OK=0; RC_REJECT=1; RC_HARNESS=10
46
+
47
+ CLOSED_KEYS="id entry requires_cwd verdict_channel verdict_enum verdict_stdout_key upstream_argv echoes_upstream approval reversibility residency degrade tier_floor writes judge verdict_binding calibration_positive_args calibration_positive_expect calibration_negative_args calibration_negative_expect"
48
+
49
+ # 「안 돌았다」를 뜻하는 이름들 — 추가조항(§ⓑ.4 B1)이 요구하는 구분항
50
+ DIDNOTRUN_NAMES="DID_NOT_RUN DIDNOTRUN NOT_RUN NO_TARGET SKIPPED UNMEASURED NOT_CONFIGURED HARNESS_ERROR"
51
+
52
+ FAILED=0 # 전 파일 누적 (종료코드용)
53
+ FILE_FAILED=0 # 현재 capfile 한정 — 파일마다 초기화한다. 이게 없으면 앞 파일의 실패가
54
+ # 뒤 파일의 M4 를 SKIPPED 로 만들어, 뒤 파일의 실제 결함이 안 보인다(보고 결함).
55
+ _fail() { printf ' ❌ %s — %s\n' "$1" "$2"; FAILED=1; FILE_FAILED=1; }
56
+ _ok() { printf ' ✅ %s — %s\n' "$1" "$2"; }
57
+ _die() { printf '❌ HARNESS_ERROR: %s\n' "$*" >&2; exit "$RC_HARNESS"; }
58
+
59
+ _parse() {
60
+ CAP_id=""; CAP_entry=""; CAP_requires_cwd=""; CAP_verdict_channel=""
61
+ CAP_verdict_enum=""; CAP_verdict_stdout_key=""; CAP_judge=""; CAP_writes=""
62
+ CAP_cal_pos_args=""; CAP_cal_pos_expect=""; CAP_cal_neg_args=""; CAP_cal_neg_expect=""
63
+ UNKNOWN_KEYS=""
64
+ local line key val
65
+ while IFS= read -r line || [ -n "$line" ]; do
66
+ case "$line" in ''|'#'*) continue ;; esac
67
+ case "$line" in *:*) ;; *) UNKNOWN_KEYS="$UNKNOWN_KEYS malformed-line"; continue ;; esac
68
+ key="${line%%:*}"; val="${line#*:}"
69
+ # POSIX 문자클래스만 쓴다. BSD sed 에서 `[ \t]` 는 탭이 아니라 «공백·역슬래시·문자 t»
70
+ # 집합이라 `exit` 의 끝 t 가 잘려 `exi` 가 된다 — 이 검사기의 known-positive 레인이
71
+ # 실제로 그걸 잡았다(2026-08-11, 자기 계기가 자기 버그를 적발한 사례).
72
+ key="$(printf '%s' "$key" | tr -d '[:space:]')"
73
+ val="$(printf '%s' "$val" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
74
+ case " $CLOSED_KEYS " in *" $key "*) ;; *) UNKNOWN_KEYS="$UNKNOWN_KEYS $key"; continue ;; esac
75
+ case "$key" in
76
+ id) CAP_id="$val" ;; entry) CAP_entry="$val" ;;
77
+ requires_cwd) CAP_requires_cwd="$val" ;; verdict_channel) CAP_verdict_channel="$val" ;;
78
+ verdict_enum) CAP_verdict_enum="$val" ;; verdict_stdout_key) CAP_verdict_stdout_key="$val" ;;
79
+ judge) CAP_judge="$val" ;; writes) CAP_writes="$val" ;;
80
+ calibration_positive_args) CAP_cal_pos_args="$val" ;;
81
+ calibration_positive_expect) CAP_cal_pos_expect="$val" ;;
82
+ calibration_negative_args) CAP_cal_neg_args="$val" ;;
83
+ calibration_negative_expect) CAP_cal_neg_expect="$val" ;;
84
+ esac
85
+ done < "$1"
86
+ }
87
+
88
+ _enum_name_of() { # $1=exit code, $2=enum string → 이름 or ""
89
+ local pair
90
+ for pair in $2; do [ "${pair%%=*}" = "$1" ] && { printf '%s' "${pair#*=}"; return 0; }; done
91
+ printf ''
92
+ }
93
+
94
+ # 🟥 capfile 은 **실행 신뢰경계**다 — M4 는 선언된 arm 을 «실제로 실행» 하는 것이 요점이므로,
95
+ # 검사기에 넘긴 capfile 은 그 자체로 "이 명령을 돌려도 된다"는 선언이다. 신뢰하지 않는
96
+ # capfile 을 이 검사기에 넘기지 마라. 배포 전 보안 패스가 실증한 것: `entry: /usr/bin/touch`
97
+ # + cal args 로 **REJECTED 판정이 나는 와중에도 부작용이 이미 발생**했다(판정 전에 arm 이 돈다).
98
+ # 아래 검문은 그 경계를 없애지 못한다 — 우발적 형태만 막는다.
99
+ _validate_arm_args() { # $1=args → 셸 메타문자/상위경로 탈출을 거부
100
+ case "$1" in
101
+ *'|'*|*';'*|*'&'*|*'>'*|*'<'*|*'`'*|*'$('*|*$'\n'*)
102
+ _fail "M4" "캘리브레이션 args 에 셸 메타문자가 있다(entry 와 같은 인젝션 표면): $1"; return 1 ;;
103
+ *'../'*)
104
+ _fail "M4" "캘리브레이션 args 가 상위 경로로 탈출한다: $1"; return 1 ;;
105
+ esac
106
+ return 0
107
+ }
108
+
109
+ _run_arm() { # $1=extra args → ARM_RC / ARM_NAME. 파이프로 읽지 않는다(PIPE-VERDICT).
110
+ ( cd "$CAP_requires_cwd" 2>/dev/null || exit 127
111
+ # shellcheck disable=SC2086 # argv 토큰 분리는 의도 (noglob 로 확장은 막혀 있다)
112
+ set -- $CAP_entry $1; "$@" ) > /dev/null 2>&1
113
+ ARM_RC=$?
114
+ ARM_NAME="$(_enum_name_of "$ARM_RC" "$CAP_verdict_enum")"
115
+ }
116
+
117
+ _check_one() {
118
+ local f="$1"
119
+ [ -r "$f" ] || _die "capfile 도달 불가: $f"
120
+ _parse "$f"
121
+ FILE_FAILED=0
122
+ printf '\n── %s (%s)\n' "${CAP_id:-<id 미선언>}" "$f"
123
+
124
+ [ -n "$UNKNOWN_KEYS" ] && _fail "SCHEMA" "닫힌 키 목록 밖:$UNKNOWN_KEYS (오타 축 무음드롭 방지 — 무시하지 않는다)"
125
+
126
+ # ── M1 실행 가능한 진입점 ──────────────────────────────────────────────────
127
+ local first second
128
+ first="$(printf '%s' "$CAP_entry" | awk '{print $1}')"
129
+ second="$(printf '%s' "$CAP_entry" | awk '{print $2}')"
130
+ case "$CAP_entry" in
131
+ *'|'*|*';'*|*'&&'*|*'>'*|*'`'*|*'$('*)
132
+ _fail "M1" "entry 가 argv 가 아니라 셸 문자열이다(파이프/리다이렉트/치환 포함) — 인젝션 표면" ;;
133
+ '') _fail "M1" "entry 미선언" ;;
134
+ *)
135
+ if [ -x "$first" ] 2>/dev/null; then _ok "M1" "실행 가능: $first"
136
+ elif command -v "$first" >/dev/null 2>&1 && [ -r "$second" ]; then
137
+ _ok "M1" "선언된 인터프리터($first) + 읽을 수 있는 스크립트: $second"
138
+ else _fail "M1" "entry 를 셸이 모델 없이 실행할 수 없다: $CAP_entry"; fi ;;
139
+ esac
140
+
141
+ # ── M2 닫힌 채널의 typed verdict + 추가조항(ran ≠ did-not-run) ─────────────
142
+ case "$CAP_verdict_channel" in
143
+ exit|stdout-key|both) ;;
144
+ *) _fail "M2" "verdict_channel 이 {exit|stdout-key|both} 밖: '${CAP_verdict_channel}'" ;;
145
+ esac
146
+ case "$CAP_verdict_channel" in
147
+ stdout-key|both) [ -n "$CAP_verdict_stdout_key" ] || _fail "M2" "channel 이 stdout-key 를 포함하는데 verdict_stdout_key 미선언" ;;
148
+ esac
149
+ if [ -z "$CAP_verdict_enum" ]; then
150
+ _fail "M2" "verdict_enum 미선언 — 열린 채널은 등록 불가"
151
+ else
152
+ local pair bad=0 has_dnr=0 nm
153
+ for pair in $CAP_verdict_enum; do
154
+ case "$pair" in *=*) ;; *) bad=1 ;; esac
155
+ nm="${pair#*=}"
156
+ case " $DIDNOTRUN_NAMES " in *" $nm "*) has_dnr=1 ;; esac
157
+ done
158
+ [ "$bad" -eq 1 ] && _fail "M2" "verdict_enum 항목이 N=NAME 형식이 아니다: $CAP_verdict_enum"
159
+ if [ "$has_dnr" -eq 1 ]; then _ok "M2" "typed enum + 「안 돌았다」 구분항: $CAP_verdict_enum"
160
+ else _fail "M2+" "enum 에 「안 돌았다」를 뜻하는 값이 없다 ($CAP_verdict_enum) — PASS 가 no-op 과 구분 불가(§ⓑ.4 B1). PASS 는 적극 증거지 실패의 부재가 아니다"; fi
161
+ fi
162
+
163
+ # ── M3 모델 독립성 (선언 검사 + 명백한 모순만) ─────────────────────────────
164
+ case "$CAP_judge" in
165
+ mechanical)
166
+ case "$CAP_entry" in
167
+ *claude*|*codex*|*gemini*|*copilot*|*ollama*|*llm*)
168
+ _fail "M3" "judge: mechanical 선언인데 entry 가 모델 CLI 를 부른다: $CAP_entry" ;;
169
+ *) _ok "M3" "judge: mechanical (모델 미개입 선언)" ;;
170
+ esac ;;
171
+ model) _ok "M3" "judge: model — 선언됨(합법). 조합에서 이 PASS 는 NON_CLEARING 이다" ;;
172
+ '') _fail "M3" "judge 축 미선언 — 모델 개입 여부가 불명이면 조합이 계산될 수 없다" ;;
173
+ *) _fail "M3" "judge 값이 {mechanical|model} 밖: '$CAP_judge'" ;;
174
+ esac
175
+
176
+ # ── M5 선언된 cwd (M4 를 그 자리에서 돌리므로 먼저) ────────────────────────
177
+ case "$CAP_requires_cwd" in
178
+ /*) [ -d "$CAP_requires_cwd" ] && _ok "M5" "requires_cwd 실재: $CAP_requires_cwd" \
179
+ || _fail "M5" "requires_cwd 가 절대경로지만 존재하지 않는다: $CAP_requires_cwd" ;;
180
+ '') _fail "M5" "requires_cwd 미선언 — 콕핏이 어디서 부를지 알 수 없다" ;;
181
+ *) _fail "M5" "requires_cwd 가 절대경로가 아니다: $CAP_requires_cwd" ;;
182
+ esac
183
+
184
+ # ── M4 캘리브레이션 쌍 — 선언 + **실행** ──────────────────────────────────
185
+ if [ -z "$CAP_cal_pos_expect" ] || [ -z "$CAP_cal_neg_expect" ]; then
186
+ _fail "M4" "캘리브레이션 쌍 미선언(양성·음성 expect 둘 다 필요) — 답을 아는 케이스를 못 가르는 계기는 재는 게 아니다"
187
+ elif [ "$FILE_FAILED" -eq 1 ] && [ -z "${CRC_FORCE_M4:-}" ]; then
188
+ printf ' ⏭ M4 — 앞선 축이 실패해 실행 생략(SKIPPED, PASS 아님)\n'
189
+ elif ! _validate_arm_args "$CAP_cal_pos_args" || ! _validate_arm_args "$CAP_cal_neg_args"; then
190
+ printf ' ⏭ M4 — args 검문 실패로 arm 을 실행하지 않았다(SKIPPED, PASS 아님)\n'
191
+ else
192
+ local pos_name neg_name pos_rc neg_rc pos_name2
193
+ _run_arm "$CAP_cal_pos_args"; pos_rc="$ARM_RC"; pos_name="$ARM_NAME"
194
+ [ "$pos_rc" = "127" ] && _fail "M4" "requires_cwd 로 진입 실패 — arm 을 돌릴 수 없다"
195
+ _run_arm "$CAP_cal_pos_args"; pos_name2="$ARM_NAME" # reps=2 (M3 부분 방어)
196
+ _run_arm "$CAP_cal_neg_args"; neg_rc="$ARM_RC"; neg_name="$ARM_NAME"
197
+
198
+ if [ -z "$pos_name" ]; then
199
+ _fail "M4" "양성 arm 의 exit $pos_rc 가 선언된 enum 밖 — enum 밖 값은 HARNESS_ERROR 이지 PASS 가 아니다"
200
+ elif [ "$pos_name" != "$CAP_cal_pos_expect" ]; then
201
+ _fail "M4" "양성 arm 이 선언과 다르다: expect=$CAP_cal_pos_expect actual=$pos_name (exit $pos_rc)"
202
+ elif [ "$pos_name" != "$pos_name2" ]; then
203
+ _fail "M4/M3" "같은 양성 arm 2회에 verdict 가 갈렸다: $pos_name vs $pos_name2 — 모델 독립성 미충족"
204
+ elif [ -z "$neg_name" ]; then
205
+ _fail "M4" "음성 arm 의 exit $neg_rc 가 선언된 enum 밖"
206
+ elif [ "$neg_name" != "$CAP_cal_neg_expect" ]; then
207
+ _fail "M4" "음성 arm 이 선언과 다르다: expect=$CAP_cal_neg_expect actual=$neg_name (exit $neg_rc)"
208
+ elif [ "$pos_name" = "$neg_name" ]; then
209
+ _fail "M4" "양성·음성이 같은 verdict($pos_name) — 답을 아는 두 케이스를 못 가른다"
210
+ else
211
+ _ok "M4" "known-pair 실행 통과: 양성→$pos_name(x2 일치) · 음성→$neg_name"
212
+ fi
213
+ fi
214
+
215
+ # ── 검증 불가 축의 정직한 표기 (판정 아님) ────────────────────────────────
216
+ [ "$CAP_writes" = "read-only" ] && \
217
+ printf ' ⚠️ writes: read-only 는 **등록자 주장**이다 — 이 검사기는 그 진위를 못 잰다(헤더 §잔여 참조)\n'
218
+ }
219
+
220
+ # ── self-test (known-pair: 통과해야 할 선언 1 · 막혀야 할 선언 6) ─────────────
221
+ _self_test() {
222
+ local T; T="$(mktemp -d)"; local pass=0 fail=0
223
+ _t() {
224
+ local o; o="$(bash "$0" "$3" 2>&1)"; local r=$?
225
+ if [ "$r" = "$2" ]; then pass=$((pass+1)); printf ' ✅ %-34s rc=%s\n' "$1" "$r"
226
+ else fail=$((fail+1)); printf ' ❌ %-34s rc=%s (기대 %s)\n%s\n' "$1" "$r" "$2" "$o"; fi
227
+ }
228
+ printf 'capability_registry_check --self-test\n'
229
+ cat > "$T/good.cap" <<EOF
230
+ id: selftest:true-false
231
+ entry: bash $T/probe.sh
232
+ requires_cwd: $T
233
+ verdict_channel: exit
234
+ verdict_enum: 0=PASS 1=FAIL 3=DID_NOT_RUN
235
+ approval: auto
236
+ reversibility: reversible
237
+ residency: public
238
+ degrade: fail-closed
239
+ tier_floor: none
240
+ writes: read-only
241
+ judge: mechanical
242
+ verdict_binding: FAIL
243
+ calibration_positive_args: ok
244
+ calibration_positive_expect: PASS
245
+ calibration_negative_args: no
246
+ calibration_negative_expect: FAIL
247
+ EOF
248
+ printf '#!/bin/sh\n[ "$1" = ok ] && exit 0\nexit 1\n' > "$T/probe.sh"
249
+ _t "known-positive: 온전한 선언" 0 "$T/good.cap"
250
+ sed 's/^verdict_enum: .*/verdict_enum: 0=PASS 1=FAIL/' "$T/good.cap" > "$T/no_dnr.cap"
251
+ _t "추가조항: 「안 돌았다」 없음" 1 "$T/no_dnr.cap"
252
+ sed 's/^calibration_negative_expect: .*/calibration_negative_expect: PASS/' "$T/good.cap" > "$T/badpair.cap"
253
+ _t "M4: 음성이 선언과 불일치" 1 "$T/badpair.cap"
254
+ grep -v '^calibration_' "$T/good.cap" > "$T/nocal.cap"
255
+ _t "M4: 캘리브레이션 쌍 미선언" 1 "$T/nocal.cap"
256
+ sed 's/^judge: .*/degrad: fail-closed/' "$T/good.cap" > "$T/typo.cap"
257
+ _t "D3: 오타 축은 무시 아닌 실패" 1 "$T/typo.cap"
258
+ sed "s|^entry: .*|entry: bash $T/probe.sh \| grep x|" "$T/good.cap" > "$T/shellstr.cap"
259
+ _t "M1: entry 가 셸 문자열" 1 "$T/shellstr.cap"
260
+ sed 's|^requires_cwd: .*|requires_cwd: relative/path|' "$T/good.cap" > "$T/relcwd.cap"
261
+ _t "M5: requires_cwd 상대경로" 1 "$T/relcwd.cap"
262
+ printf '\n 통과 %d · 실패 %d\n' "$pass" "$fail"
263
+ rm -rf "$T"
264
+ [ "$fail" -eq 0 ] || return 1
265
+ return 0
266
+ }
267
+
268
+ [ "${1:-}" = "--self-test" ] && { _self_test; exit $?; }
269
+ [ $# -ge 1 ] || _die "capfile 인자가 없다. 사용법: $0 <capfile> [<capfile>...]"
270
+
271
+ printf 'capability_registry_check — M1–M5 + 추가조항 (등록 시점)\n'
272
+ for f in "$@"; do _check_one "$f"; done
273
+
274
+ printf '\n'
275
+ if [ "$FAILED" -eq 0 ]; then
276
+ printf '✅ REGISTRABLE — 전 capfile 이 M1–M5 + 추가조항 통과\n'; exit "$RC_OK"
277
+ else
278
+ printf '❌ REJECTED — 등록 불가. 그 표면은 오늘 있던 자리(dispatch 엔트리)에 그대로 남는다 — 손실이 아니다\n'
279
+ exit "$RC_REJECT"
280
+ fi