@ccoalm/ccl-skills 0.9.0 → 0.11.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.
Files changed (57) hide show
  1. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/SKILL.md +4 -3
  2. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +23 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +178 -4
  4. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +127 -0
  5. package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/SKILL.md +2 -1
  6. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/SKILL.md +1 -1
  7. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +1 -0
  8. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/SKILL.md +11 -1
  9. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/async-lifecycle-and-performance.md +16 -0
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/source-map.md +1 -0
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +8 -8
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/delivery-lifecycle.md +1 -1
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/dispatch-owner-skills.md +9 -1
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/problem-resolution-and-learning.md +2 -0
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/SKILL.md +1 -1
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/tag-and-prod-pipeline-gate.md +9 -0
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +17 -20
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/attention-budget-ratchet.md +37 -0
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/description-authoring.md +9 -0
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +26 -44
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/eval-routing.md +24 -3
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +16 -0
  23. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +5 -5
  24. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/rule-consolidation.md +3 -1
  25. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +59 -0
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +2 -2
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +3 -1
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +35 -0
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-contract-anchors.sh +126 -0
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-size-budget.sh +197 -1
  31. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/contract-anchors.tsv +16 -0
  32. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/eval-routing-bank.rb +210 -36
  33. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/extraction_review_gate.sh +3 -3
  34. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/gate_receipt.py +576 -0
  35. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +35 -6
  36. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/review_ledger_binding.py +476 -0
  37. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_antipattern_grep_panel.sh +80 -0
  38. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_body_compliance_grading.sh +99 -0
  39. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_impact_chain_refscripts.sh +81 -1
  40. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +28 -0
  41. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_size_budget.sh +251 -0
  42. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_contract_anchors.sh +196 -0
  43. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_eval_routing_bank_grader_diagnostics.sh +222 -0
  44. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_extraction_review_gate.sh +16 -10
  45. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_frozen_case_sanctity.sh +178 -0
  46. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_frozen_case_sanctity_selfproof.sh +108 -0
  47. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_gate_receipt.sh +431 -0
  48. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_pinned_phrase_mutation_walk.sh +151 -0
  49. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_review_ledger_binding.sh +336 -0
  50. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_bank_integrity.sh +86 -5
  51. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_pointer_integrity.sh +41 -1
  52. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +141 -28
  53. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +139 -38
  54. package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/SKILL.md +1 -1
  55. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +9 -8
  56. package/dist/assets/release.json +110 -45
  57. package/package.json +1 -1
@@ -0,0 +1,576 @@
1
+ #!/usr/bin/env python3
2
+ """Mint and verify candidate-SHA-bound receipts of deterministic-gate output.
3
+
4
+ A gate receipt binds one deterministic command run to the exact committed
5
+ candidate state it ran against, so a historical-process claim ("the suite was
6
+ RED before the fix", "heavy lane was green at landing") can ride the review
7
+ packet as falsifiable evidence instead of implementer testimony.
8
+
9
+ Trust model (deliberately narrow — do not oversell in referencing surfaces):
10
+ - A receipt is candidate-bound, re-runnable CONSISTENCY evidence: anyone at
11
+ the recorded commit can re-execute the command and compare exit code and
12
+ output hash (`verify --rerun`).
13
+ - A receipt does NOT authenticate who ran the command or that the recorded
14
+ output was not fabricated; the minter and the candidate author are the same
15
+ party. Deterministic authority stays with CI re-running the gate on the
16
+ actual branch. A receipt upgrades a process claim from "unverifiable" to
17
+ "falsifiable"; it never substitutes for the CI lane.
18
+ - Minting refuses a dirty tree: an uncommitted candidate has no stable SHA to
19
+ bind, and a receipt minted against drifting files would be unfalsifiable.
20
+
21
+ Commands run as given (argv, no shell). Receipts of RED runs are first-class:
22
+ `mint` records the exit code, it does not require success — a pre-fix RED
23
+ receipt is the canonical use case.
24
+
25
+ Usage:
26
+ gate_receipt.py mint --out FILE [--tail-bytes N] [--timeout SECONDS] -- CMD [ARG...]
27
+ gate_receipt.py verify FILE [--rerun [--exit-only] -- CMD [ARG...]]
28
+
29
+ A receipt is untrusted data, so `verify --rerun` NEVER executes the recorded
30
+ argv: the verifier supplies the command they intend to re-run, and the tool
31
+ compares it against the recorded argv (mismatch is a named rc 1) before
32
+ executing the verifier's own words. A hostile receipt can therefore misdescribe
33
+ a gate but cannot make the verifier run anything they did not type.
34
+
35
+ The receipt records the repository-relative working directory and re-runs
36
+ execute from it (same argv elsewhere is a different command). The candidate is
37
+ re-read after the run on both sides; HEAD or cleanliness moving mid-run refuses
38
+ a result. The output tail is OFF by default (--tail-bytes 0): exit code plus
39
+ output hash suffice for verification, and a verbatim excerpt would copy
40
+ whatever the gate printed — including a leaked token — into the ledger; opt in
41
+ deliberately, and receipts are created 0600.
42
+
43
+ Exit codes: 0 ok; 1 receipt fails verification (structural or rerun mismatch,
44
+ named reason on stderr); 2 usage/environment error (not a verdict on the
45
+ receipt: dirty tree, wrong checked-out candidate, timeout, bad invocation).
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import argparse
51
+ import hashlib
52
+ import json
53
+ import os
54
+ import re
55
+ import signal
56
+ import subprocess
57
+ import sys
58
+ import threading
59
+ import time
60
+ from datetime import datetime, timezone
61
+ from pathlib import Path
62
+
63
+ SCHEMA_VERSION = 1
64
+ KIND = "gate-receipt"
65
+ RECEIPT_KEYS = {
66
+ "schema_version",
67
+ "kind",
68
+ "candidate_commit",
69
+ "tree_clean",
70
+ "cwd",
71
+ "tail_bytes",
72
+ "command",
73
+ "exit_code",
74
+ "output_bytes",
75
+ "output_sha256",
76
+ "output_tail",
77
+ "minted_at",
78
+ }
79
+ MAX_RECEIPT_BYTES = 65536
80
+ MAX_TAIL_BYTES = 16384
81
+ COMMIT_RE = re.compile(r"^[0-9a-f]{40}([0-9a-f]{24})?$")
82
+ SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
83
+ RFC3339_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$")
84
+
85
+
86
+ def infra(message: str) -> "int":
87
+ print(f"gate_receipt_error: {message}", file=sys.stderr)
88
+ return 2
89
+
90
+
91
+ def invalid(message: str) -> int:
92
+ print(f"gate_receipt_invalid: {message}", file=sys.stderr)
93
+ return 1
94
+
95
+
96
+ def git_output(args: list[str]) -> str:
97
+ # Bytes in, tolerant decode out: with core.quotePath=false an untracked
98
+ # filename holding invalid UTF-8 would make text-mode decoding raise, and
99
+ # an uncaught decode error exits with the status reserved for a failed
100
+ # receipt verdict. The callers only compare/strip this output, so
101
+ # replacement characters are safe — non-empty stays non-empty.
102
+ result = subprocess.run(["git", *args], capture_output=True, check=False)
103
+ if result.returncode != 0:
104
+ stderr = result.stderr.decode("utf-8", errors="replace").strip()
105
+ raise RuntimeError(f"git {' '.join(args)} failed: {stderr}")
106
+ return result.stdout.decode("utf-8", errors="replace")
107
+
108
+
109
+ def candidate_state() -> tuple[str, bool]:
110
+ head = git_output(["rev-parse", "HEAD"]).strip()
111
+ # Explicit flags: a repo config of status.showUntrackedFiles=no (or a
112
+ # submodule-ignoring setting) would otherwise report clean while untracked
113
+ # gate inputs exist — content absent from the recorded commit.
114
+ porcelain = git_output(
115
+ ["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"]
116
+ )
117
+ return head, porcelain == ""
118
+
119
+
120
+ def repo_relative_cwd() -> str:
121
+ """Current directory relative to the repository toplevel ('.' at the root).
122
+
123
+ Recorded in the receipt so a re-run executes the command from the same
124
+ place — the same argv from a different directory is a different command.
125
+ """
126
+ top = git_output(["rev-parse", "--show-toplevel"]).strip()
127
+ rel = os.path.relpath(os.getcwd(), top)
128
+ if rel == ".." or rel.startswith(".." + os.sep):
129
+ raise RuntimeError("working directory escapes the repository toplevel")
130
+ return rel
131
+
132
+
133
+ def bounded_utf8_tail(tail: bytes, budget: int) -> str:
134
+ """Decode with replacement, then trim from the FRONT until the encoded
135
+ UTF-8 size fits the budget — replacement characters can expand invalid
136
+ bytes threefold, and a receipt whose own tail fails the verifier's size
137
+ check would be minted broken."""
138
+ text = tail.decode("utf-8", errors="replace")
139
+ while text and len(text.encode("utf-8")) > budget:
140
+ overshoot = len(text.encode("utf-8")) - budget
141
+ text = text[max(1, overshoot // 4):]
142
+ return text
143
+
144
+
145
+ def normalize_exit(code: int) -> int:
146
+ """Map a signal-terminated child (negative Popen returncode) to the shell
147
+ convention 128+N, so a legitimately RED signal-killed gate still yields a
148
+ structurally valid receipt (0..255) and re-runs compare consistently."""
149
+ if code < 0:
150
+ return 128 + (-code)
151
+ return code & 0xFF
152
+
153
+
154
+ def run_and_capture(command: list[str], tail_bytes: int, timeout: int, cwd: str | None = None):
155
+ """Run argv, stream-hash combined stdout+stderr, keep a bounded tail.
156
+
157
+ The child gets its own process group and /dev/null stdin; a reader thread
158
+ drains the pipe while the main thread holds the deadline, so a silent
159
+ hanging gate (or a descendant keeping the pipe open) cannot block past
160
+ --timeout — on expiry the whole group is killed and no result is returned.
161
+ """
162
+ digest = hashlib.sha256()
163
+ tail = bytearray()
164
+ total = 0
165
+ proc = subprocess.Popen(
166
+ command,
167
+ stdin=subprocess.DEVNULL,
168
+ stdout=subprocess.PIPE,
169
+ stderr=subprocess.STDOUT,
170
+ start_new_session=True,
171
+ cwd=cwd,
172
+ )
173
+ assert proc.stdout is not None
174
+
175
+ def drain() -> None:
176
+ nonlocal total
177
+ while True:
178
+ chunk = proc.stdout.read(65536)
179
+ if not chunk:
180
+ break
181
+ digest.update(chunk)
182
+ total += len(chunk)
183
+ tail.extend(chunk)
184
+ if len(tail) > tail_bytes:
185
+ del tail[: len(tail) - tail_bytes]
186
+
187
+ reader = threading.Thread(target=drain, daemon=True)
188
+ reader.start()
189
+ deadline = time.monotonic() + timeout
190
+
191
+ def kill_group() -> None:
192
+ try:
193
+ os.killpg(proc.pid, signal.SIGKILL)
194
+ except (ProcessLookupError, PermissionError):
195
+ proc.kill()
196
+
197
+ try:
198
+ exit_code = proc.wait(timeout=timeout)
199
+ except subprocess.TimeoutExpired:
200
+ kill_group()
201
+ proc.wait()
202
+ reader.join(timeout=10)
203
+ raise
204
+ # The child exited; the pipe closes only when every writer (including any
205
+ # surviving descendant) has closed it. Give the reader the remaining
206
+ # deadline, then treat a still-open pipe as a timeout: output would be
207
+ # incomplete, so no receipt/verdict may be produced from it.
208
+ reader.join(timeout=max(0.0, deadline - time.monotonic()))
209
+ if reader.is_alive():
210
+ kill_group()
211
+ reader.join(timeout=10)
212
+ raise subprocess.TimeoutExpired(command, timeout)
213
+ return normalize_exit(exit_code), total, digest.hexdigest(), bytes(tail)
214
+
215
+
216
+ def mint(args: argparse.Namespace) -> int:
217
+ out = Path(args.out)
218
+ if not args.command:
219
+ return infra("mint requires a command after --")
220
+ if not (0 <= args.tail_bytes <= MAX_TAIL_BYTES):
221
+ return infra(f"--tail-bytes must be in 0..{MAX_TAIL_BYTES}")
222
+ try:
223
+ head, clean = candidate_state()
224
+ rel_cwd = repo_relative_cwd()
225
+ top = git_output(["rev-parse", "--show-toplevel"]).strip()
226
+ except RuntimeError as exc:
227
+ return infra(str(exc))
228
+ # The documented contract is receipts-outside-the-candidate-tree; enforce
229
+ # it instead of trusting it. An in-tree receipt dirties the tree AFTER the
230
+ # cleanliness checks ran (created last), so it would mint "successfully"
231
+ # and then fail every re-run as dirty_tree — and a path under .git could
232
+ # mutate repository metadata porcelain status never shows.
233
+ out_abs = os.path.realpath(os.path.join(os.getcwd(), str(out)))
234
+ top_real = os.path.realpath(top)
235
+ if os.path.commonpath([top_real, out_abs]) == top_real:
236
+ return infra(
237
+ f"--out {out} resolves inside the candidate repository; "
238
+ "write receipts OUTSIDE the tree (e.g. the chain ledger directory)"
239
+ )
240
+ if not clean:
241
+ return infra(
242
+ "tree not clean — a receipt binds to a committed candidate; "
243
+ "commit first, then mint (and write receipts OUTSIDE the candidate "
244
+ "tree, e.g. the chain ledger directory — an in-tree receipt dirties "
245
+ "the tree it binds)"
246
+ )
247
+ try:
248
+ exit_code, total, output_sha, tail = run_and_capture(
249
+ args.command, args.tail_bytes, args.timeout
250
+ )
251
+ except FileNotFoundError as exc:
252
+ return infra(f"command not found: {exc}")
253
+ except subprocess.TimeoutExpired:
254
+ return infra(f"command exceeded --timeout {args.timeout}s; no receipt minted")
255
+ # Re-read the candidate AFTER the run: HEAD or cleanliness moving mid-run
256
+ # (a concurrent checkout/rebase, or the gate itself committing) means the
257
+ # output belongs to no single candidate — refuse rather than mislabel.
258
+ try:
259
+ head_after, clean_after = candidate_state()
260
+ except RuntimeError as exc:
261
+ return infra(str(exc))
262
+ if head_after != head or not clean_after:
263
+ return infra(
264
+ "candidate changed during the run "
265
+ f"(HEAD {head} -> {head_after}, clean={clean_after}); no receipt minted"
266
+ )
267
+ receipt = {
268
+ "schema_version": SCHEMA_VERSION,
269
+ "kind": KIND,
270
+ "candidate_commit": head,
271
+ "tree_clean": True,
272
+ "cwd": rel_cwd,
273
+ "command": list(args.command),
274
+ "exit_code": exit_code,
275
+ "output_bytes": total,
276
+ "output_sha256": output_sha,
277
+ "tail_bytes": args.tail_bytes,
278
+ # Off by default: exit code + output hash suffice for verification, and
279
+ # a verbatim tail would copy whatever the gate printed — including a
280
+ # leaked token — into ledger/packet-adjacent evidence. Opt in with
281
+ # --tail-bytes N when the excerpt is genuinely needed.
282
+ "output_tail": bounded_utf8_tail(tail, args.tail_bytes) if args.tail_bytes else "",
283
+ "minted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
284
+ }
285
+ encoded = json.dumps(
286
+ receipt, ensure_ascii=False, sort_keys=True, indent=1
287
+ ).encode("utf-8")
288
+ if len(encoded) + 1 > MAX_RECEIPT_BYTES:
289
+ return infra(
290
+ f"receipt would be {len(encoded) + 1} bytes, over the "
291
+ f"{MAX_RECEIPT_BYTES} cap its own verifier enforces (oversized "
292
+ "argv?); no receipt minted"
293
+ )
294
+ # Re-check containment AFTER the gate ran: a parent directory swapped to a
295
+ # symlink into the repository between the pre-run check and this write
296
+ # would otherwise land the receipt in-tree (O_NOFOLLOW guards only the
297
+ # final component). A swap in the instant between this check and os.open
298
+ # remains possible; anything able to race writes in the ledger directory
299
+ # could already rewrite receipts, so that residue is inside the existing
300
+ # trust boundary.
301
+ out_abs = os.path.realpath(os.path.join(os.getcwd(), str(out)))
302
+ if os.path.commonpath([top_real, out_abs]) == top_real:
303
+ return infra(
304
+ f"--out {out} resolves inside the candidate repository after the "
305
+ "run (parent directory changed?); no receipt minted"
306
+ )
307
+ # Write a unique 0600 temp file in the destination directory, then publish
308
+ # with a no-overwrite atomic link. A failed/partial write therefore never
309
+ # occupies the final name (O_EXCL retries stay possible), and existence of
310
+ # the final name is decided by the same syscall that creates it.
311
+ tmp = Path(f"{out}.tmp.{os.getpid()}")
312
+ open_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
313
+ open_flags |= getattr(os, "O_NOFOLLOW", 0)
314
+ try:
315
+ fd = os.open(tmp, open_flags, 0o600)
316
+ except OSError as exc:
317
+ return infra(f"cannot create temp receipt {tmp}: {exc}")
318
+ publish_error: str | None = None
319
+ try:
320
+ with os.fdopen(fd, "wb") as handle:
321
+ handle.write(encoded + b"\n")
322
+ try:
323
+ os.link(tmp, out)
324
+ except FileExistsError:
325
+ publish_error = f"--out {out} already exists; receipts are never overwritten"
326
+ except OSError as exc:
327
+ publish_error = f"cannot publish receipt {out}: {exc}"
328
+ except OSError as exc:
329
+ publish_error = f"failed to write receipt {tmp}: {exc}"
330
+ finally:
331
+ try:
332
+ os.unlink(tmp)
333
+ except OSError as exc:
334
+ # Report honestly instead of claiming a clean state.
335
+ cleanup_note = f"; temp file {tmp} could not be removed: {exc}"
336
+ publish_error = (publish_error or "receipt published") + cleanup_note
337
+ if publish_error.startswith("receipt published"):
338
+ print(f"gate_receipt_warning: {cleanup_note.lstrip('; ')}", file=sys.stderr)
339
+ publish_error = None
340
+ if publish_error:
341
+ return infra(publish_error)
342
+ receipt_sha = hashlib.sha256(encoded + b"\n").hexdigest()
343
+ print(
344
+ f"gate_receipt_minted: {out} sha256={receipt_sha} "
345
+ f"candidate={head} exit={exit_code}"
346
+ )
347
+ return 0
348
+
349
+
350
+ def load_receipt(path: Path) -> dict | int:
351
+ """Return the parsed receipt dict, or an int exit code on failure."""
352
+ if path.is_symlink() or not path.is_file():
353
+ return infra(f"{path} must be a regular non-linked file")
354
+ # Bounded no-follow read: never pull an oversized file into memory just to
355
+ # discover it is over the cap.
356
+ read_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
357
+ try:
358
+ fd = os.open(path, read_flags)
359
+ except OSError as exc:
360
+ return infra(f"cannot open {path}: {exc}")
361
+ with os.fdopen(fd, "rb") as handle:
362
+ raw = handle.read(MAX_RECEIPT_BYTES + 1)
363
+ if len(raw) > MAX_RECEIPT_BYTES:
364
+ return invalid(f"receipt exceeds {MAX_RECEIPT_BYTES} bytes")
365
+ try:
366
+ payload = json.loads(raw.decode("utf-8"))
367
+ except (UnicodeError, json.JSONDecodeError) as exc:
368
+ return invalid(f"receipt is not valid UTF-8 JSON: {exc}")
369
+ if not isinstance(payload, dict) or set(payload) != RECEIPT_KEYS:
370
+ return invalid("receipt must contain exactly the gate-receipt key set")
371
+ if payload["schema_version"] != SCHEMA_VERSION or type(payload["schema_version"]) is not int:
372
+ return invalid("schema_version must be 1")
373
+ if payload["kind"] != KIND:
374
+ return invalid("kind must be gate-receipt")
375
+ if not isinstance(payload["candidate_commit"], str) or not COMMIT_RE.match(
376
+ payload["candidate_commit"]
377
+ ):
378
+ return invalid("candidate_commit must be a full lowercase git commit hash")
379
+ if payload["tree_clean"] is not True:
380
+ return invalid("tree_clean must be true — a dirty-tree receipt binds nothing")
381
+ rel_cwd = payload["cwd"]
382
+ if (
383
+ not isinstance(rel_cwd, str)
384
+ or not rel_cwd
385
+ or os.path.isabs(rel_cwd)
386
+ or ".." in rel_cwd.split("/")
387
+ ):
388
+ return invalid("cwd must be a repository-relative path with no parent escapes")
389
+ command = payload["command"]
390
+ if (
391
+ not isinstance(command, list)
392
+ or not command
393
+ or not all(isinstance(part, str) for part in command)
394
+ or not command[0]
395
+ ):
396
+ return invalid(
397
+ "command must be a non-empty list of strings with a non-empty "
398
+ "executable (later arguments may legitimately be empty)"
399
+ )
400
+ if type(payload["exit_code"]) is not int or not (0 <= payload["exit_code"] <= 255):
401
+ return invalid("exit_code must be an integer in 0..255")
402
+ if type(payload["output_bytes"]) is not int or payload["output_bytes"] < 0:
403
+ return invalid("output_bytes must be a non-negative integer")
404
+ if not isinstance(payload["output_sha256"], str) or not SHA256_RE.match(
405
+ payload["output_sha256"]
406
+ ):
407
+ return invalid("output_sha256 must be 64 lowercase hex chars")
408
+ tail_budget = payload["tail_bytes"]
409
+ if type(tail_budget) is not int or not (0 <= tail_budget <= MAX_TAIL_BYTES):
410
+ return invalid(f"tail_bytes must be an integer in 0..{MAX_TAIL_BYTES}")
411
+ tail = payload["output_tail"]
412
+ if not isinstance(tail, str) or len(tail.encode("utf-8")) > MAX_TAIL_BYTES:
413
+ return invalid(f"output_tail must be a string of at most {MAX_TAIL_BYTES} UTF-8 bytes")
414
+ if tail_budget == 0 and tail:
415
+ return invalid("output_tail must be empty when tail_bytes is 0")
416
+ if not isinstance(payload["minted_at"], str) or not RFC3339_RE.match(
417
+ payload["minted_at"]
418
+ ):
419
+ return invalid("minted_at must be an RFC3339 timestamp")
420
+ return payload
421
+
422
+
423
+ def verify(args: argparse.Namespace) -> int:
424
+ path = Path(args.receipt)
425
+ loaded = load_receipt(path)
426
+ if isinstance(loaded, int):
427
+ return loaded
428
+ if not args.rerun:
429
+ if args.exit_only:
430
+ return infra("--exit-only requires --rerun")
431
+ if args.command:
432
+ return infra("a command after -- requires --rerun")
433
+ print(f"gate_receipt_structural_ok: {path}")
434
+ return 0
435
+ # The receipt is untrusted input: never execute its recorded argv. The
436
+ # verifier states the command; a mismatch against the record is a named
437
+ # verification failure, and only the verifier-typed argv ever runs.
438
+ supplied = list(args.command)
439
+ if not supplied:
440
+ return infra(
441
+ "--rerun requires the expected gate command after -- ; "
442
+ "re-running the receipt's own recorded argv would execute "
443
+ "candidate-controlled input"
444
+ )
445
+ if supplied != loaded["command"]:
446
+ return invalid(
447
+ "command_mismatch: receipt records "
448
+ f"{loaded['command']!r}, verifier supplied {supplied!r}"
449
+ )
450
+ try:
451
+ head, clean = candidate_state()
452
+ except RuntimeError as exc:
453
+ return infra(str(exc))
454
+ if head != loaded["candidate_commit"]:
455
+ return infra(
456
+ f"wrong_candidate: HEAD is {head}, receipt binds "
457
+ f"{loaded['candidate_commit']} — check out the recorded commit to re-run"
458
+ )
459
+ if not clean:
460
+ return infra("dirty_tree: re-run verification requires a clean tree")
461
+ try:
462
+ top = git_output(["rev-parse", "--show-toplevel"]).strip()
463
+ except RuntimeError as exc:
464
+ return infra(str(exc))
465
+ rundir = os.path.normpath(os.path.join(top, loaded["cwd"]))
466
+ # The recorded cwd is untrusted receipt data: resolve it and require the
467
+ # REAL path to stay inside the repository — a committed in-repo symlink
468
+ # pointing outside would otherwise make the verifier's relative argv
469
+ # execute from an attacker-chosen external directory.
470
+ top_real = os.path.realpath(top)
471
+ rundir_real = os.path.realpath(rundir)
472
+ if os.path.commonpath([top_real, rundir_real]) != top_real:
473
+ return infra(
474
+ f"recorded cwd resolves outside the repository: {loaded['cwd']}"
475
+ )
476
+ if not os.path.isdir(rundir_real):
477
+ return infra(f"recorded cwd does not exist in this checkout: {loaded['cwd']}")
478
+ try:
479
+ # Capture the tail with the RECORDED budget so the reconstruction
480
+ # below runs the exact pipeline mint ran — identical output bytes then
481
+ # reconstruct byte-identically, with no window-shape divergence.
482
+ exit_code, total, output_sha, observed_tail = run_and_capture(
483
+ supplied, loaded["tail_bytes"], args.timeout, cwd=rundir
484
+ )
485
+ except FileNotFoundError as exc:
486
+ return infra(f"command not found: {exc}")
487
+ except subprocess.TimeoutExpired:
488
+ return infra(f"re-run exceeded --timeout {args.timeout}s; no verdict")
489
+ try:
490
+ head_after, clean_after = candidate_state()
491
+ except RuntimeError as exc:
492
+ return infra(str(exc))
493
+ if head_after != head or not clean_after:
494
+ return infra(
495
+ "candidate changed during the re-run "
496
+ f"(HEAD {head} -> {head_after}, clean={clean_after}); no verdict"
497
+ )
498
+ if exit_code != loaded["exit_code"]:
499
+ return invalid(
500
+ f"exit_code_mismatch: observed {exit_code}, recorded {loaded['exit_code']}"
501
+ )
502
+ if not args.exit_only:
503
+ if output_sha != loaded["output_sha256"]:
504
+ return invalid(
505
+ "output_hash_mismatch: observed "
506
+ f"{output_sha}, recorded {loaded['output_sha256']} "
507
+ f"(observed_bytes={total}, recorded_bytes={loaded['output_bytes']}); "
508
+ "if the gate's output is legitimately nondeterministic, "
509
+ "re-verify with --exit-only and say so in the referencing row"
510
+ )
511
+ # Every recorded field must be compared, or a forged value in it rides
512
+ # a "full" pass: byte count exactly, and a non-empty recorded tail must
513
+ # be a suffix of the observed output's decoded tail window.
514
+ if total != loaded["output_bytes"]:
515
+ return invalid(
516
+ f"output_bytes_mismatch: observed {total}, "
517
+ f"recorded {loaded['output_bytes']}"
518
+ )
519
+ # Reconstruct the tail with the SAME deterministic function and the
520
+ # RECORDED budget, then require exact equality: a truncated, emptied,
521
+ # or padded tail all fail — a suffix check would accept truncation and
522
+ # an empty tail would skip comparison entirely.
523
+ tail_budget = loaded["tail_bytes"]
524
+ reconstructed = (
525
+ bounded_utf8_tail(observed_tail, tail_budget) if tail_budget else ""
526
+ )
527
+ if reconstructed != loaded["output_tail"]:
528
+ return invalid(
529
+ "output_tail_mismatch: reconstructing the tail at the "
530
+ f"recorded tail_bytes={tail_budget} does not reproduce the "
531
+ "recorded output_tail (same capture window and trim pipeline "
532
+ "as mint, so identical output implies identical tails)"
533
+ )
534
+ scope = "exit-only" if args.exit_only else "full"
535
+ print(f"gate_receipt_rerun_ok: {path} scope={scope}")
536
+ return 0
537
+
538
+
539
+ def main() -> int:
540
+ parser = argparse.ArgumentParser(prog="gate_receipt.py", add_help=True)
541
+ sub = parser.add_subparsers(dest="mode", required=True)
542
+ mint_parser = sub.add_parser("mint")
543
+ mint_parser.add_argument("--out", required=True)
544
+ mint_parser.add_argument("--tail-bytes", type=int, default=0)
545
+ mint_parser.add_argument("--timeout", type=int, default=3600)
546
+ verify_parser = sub.add_parser("verify")
547
+ verify_parser.add_argument("receipt")
548
+ verify_parser.add_argument("--rerun", action="store_true")
549
+ verify_parser.add_argument("--exit-only", action="store_true")
550
+ verify_parser.add_argument("--timeout", type=int, default=3600)
551
+ # The gate command is everything after the first standalone `--`, split
552
+ # BEFORE argparse sees it: argparse.REMAINDER is greedy and would swallow
553
+ # flags like --rerun that appear between the positional and the `--`.
554
+ argv = sys.argv[1:]
555
+ command_tail: list[str] = []
556
+ if "--" in argv:
557
+ split_at = argv.index("--")
558
+ command_tail = argv[split_at + 1:]
559
+ argv = argv[:split_at]
560
+ args = parser.parse_args(argv)
561
+ args.command = command_tail
562
+ # An environment failure (permission denied on the gate binary or the
563
+ # receipt path, a vanished directory, a full disk) must never surface as
564
+ # the exit status the CLI contract reserves for "the receipt failed
565
+ # verification" — an uncaught traceback exits 1, which would be a false
566
+ # verdict. Everything unexpected at the OS layer is rc 2, no verdict.
567
+ try:
568
+ if args.mode == "mint":
569
+ return mint(args)
570
+ return verify(args)
571
+ except (OSError, UnicodeError) as exc:
572
+ return infra(f"environment failure, no verdict: {exc}")
573
+
574
+
575
+ if __name__ == "__main__":
576
+ sys.exit(main())
@@ -1327,19 +1327,48 @@ if upstream.any? || routing_entrypoint_changed || changed_paths.include?(LEDGER_
1327
1327
  # path is broken gets the firing-path message; every other failure shape
1328
1328
  # gets the behavior-evidence message. (A declared wording-only row can
1329
1329
  # never be a RED row, so no exclusion is needed here.)
1330
- firing_path_incomplete = evaluated.any? do |entry|
1331
- entry[:red_declared] && !entry[:firing_path_valid]
1332
- end
1333
- if firing_path_incomplete
1334
- firing_path_failures << path
1330
+ # Name the OFFENDING ROWS, not just the owner. The owner-level `all?` above
1331
+ # means one bad row reddens the package, so an owner-only diagnostic sends the
1332
+ # author to inspect whichever row they were just writing — which is usually the
1333
+ # correct one. Observed: a round whose new row was fine failed because an
1334
+ # unrelated ALREADY-COMMITTED ledger row had been edited by an unbounded string
1335
+ # replace; the edit made that old row an added row of this round, so its own
1336
+ # original anchor no longer resolved here. Three iterations went into rewriting
1337
+ # the good anchor before the real offender was found by patching debug output
1338
+ # into this script. The row identity is what closes that gap.
1339
+ offending_rows = evaluated.each_index.select do |index|
1340
+ evaluated[index][:red_declared] && !evaluated[index][:firing_path_valid]
1341
+ end.map { |index| owner_rows[index] }
1342
+ if offending_rows.any?
1343
+ firing_path_failures << { path: path, rows: offending_rows }
1335
1344
  else
1336
1345
  behavior_failures << path
1337
1346
  end
1338
1347
  end
1348
+ # First cell of a ledger row, bounded: enough to recognise which row is meant
1349
+ # without dumping a multi-thousand-character evidence cell into the diagnostic.
1350
+ # This renders REPOSITORY TEXT a contributor controls onto a terminal/CI channel,
1351
+ # so it is scrubbed before it is printed, not merely bounded. C0/C1 controls,
1352
+ # bidi overrides, and zero-width characters can erase earlier output, restyle it,
1353
+ # or reorder the rendered row so the named offender is not the one a reader sees —
1354
+ # a length cap does nothing about any of that. Invalid bytes are replaced first so
1355
+ # the escape pass cannot raise on a malformed cell. Both reviewer lanes raised this
1356
+ # independently.
1357
+ CONTROL_OR_INVISIBLE = /[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u2028-\u202E\u2060-\u2064\u206A-\u206F\uFEFF]/.freeze
1358
+ row_label = lambda do |row|
1359
+ cell = row[:line].to_s.sub(/\A\+/, "").split("|")[1].to_s.strip
1360
+ cell = cell.empty? ? row[:line].to_s.strip : cell
1361
+ visible = cell.scrub("?").gsub(CONTROL_OR_INVISIBLE) { |ch| format("\\u%04X", ch.ord) }
1362
+ visible.length > 120 ? "#{visible[0, 117]}..." : visible
1363
+ end
1339
1364
  unless firing_path_failures.empty?
1340
1365
  warn "impact_chain_firing_path_missing: RED-baseline row has no owner-scoped firing path in this committed diff"
1341
1366
  warn " fix: point to this owner with `firing-path: command:<changed repo executable>` or `firing-path: file:<changed markdown>#<unique token on a changed numbered/list rule with a normative action>`"
1342
- firing_path_failures.each { |path| warn " incomplete: #{path}" }
1367
+ warn " note: the rows named below are the ones that failed — every RED row bound to an owner must resolve, so a row you did not intend to touch can redden the owner. If a named row is one you only EDITED, it became an added row of this round and its original anchor no longer resolves against this round's diff: restore that row rather than rewriting an anchor that was already correct."
1368
+ firing_path_failures.each do |entry|
1369
+ warn " incomplete: #{entry[:path]}"
1370
+ entry[:rows].each { |row| warn " offending row: #{row_label.call(row)}" }
1371
+ end
1343
1372
  exit 1
1344
1373
  end
1345
1374
  unless behavior_failures.empty?