@ccoalm/ccl-skills 0.6.2 → 0.8.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 (139) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +11 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/hooks/remind-unverified-cli-flag.sh +309 -0
  4. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_remind_unverified_cli_flag.sh +483 -0
  5. package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +5 -0
  6. package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/SKILL.md +10 -8
  7. package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/mobile-quality-release.md +1 -1
  8. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/SKILL.md +16 -17
  9. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/client-routing.md +1 -1
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +195 -7
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/timeout-auth-and-capabilities.md +3 -3
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/claude_review.sh +13 -5
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/codex_review.sh +9 -3
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/kimi_review.sh +9 -3
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/normalize_review_timeout.sh +22 -0
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/opencode_review.sh +9 -3
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +1540 -129
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_claude_review_probe.sh +8 -3
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_client_compat.py +76 -1
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +1858 -3
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_update_review_plan_intent.sh +789 -0
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/update_review_plan_intent.py +513 -0
  23. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/SKILL.md +1 -1
  24. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/architecture-playbook.md +2 -0
  25. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/data-platform-architecture.md +1 -1
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/event-driven-architecture.md +14 -11
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/multi-tenant-isolation.md +2 -2
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/SKILL.md +5 -1
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +2 -1
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/SKILL.md +13 -11
  31. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/SKILL.md +64 -0
  32. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/agents/openai.yaml +4 -0
  33. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/async-lifecycle-and-performance.md +72 -0
  34. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/runtime-and-project-contract.md +58 -0
  35. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/source-map.md +41 -0
  36. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/verification-diagnostics-and-security.md +63 -0
  37. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/SKILL.md +1 -1
  38. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/sli-slo-design.md +25 -9
  39. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/source-register.md +1 -0
  40. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/SKILL.md +1 -1
  41. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/promotion-gate-and-review.md +16 -0
  42. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/secret-and-config-management.md +7 -0
  43. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/retry-timeout-circuit-breaker.md +11 -0
  44. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +8 -10
  45. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/design-routing-and-readiness.md +10 -14
  46. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/verify-developer-experience.md +1 -1
  47. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/SKILL.md +135 -86
  48. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/behavioral-aesthetic-logic.md +66 -80
  49. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/delivery-contract.md +275 -0
  50. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-execution-checklist.md +88 -214
  51. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-impl-naming-and-versioning.md +2 -2
  52. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-intake-and-acceptance.md +10 -8
  53. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-system-source-of-truth.md +4 -5
  54. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/external-ui-ux-quality-benchmarks.md +112 -95
  55. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/frontend-code-evidence-map.md +30 -21
  56. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/interaction-design-patterns.md +22 -3
  57. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/layout-recipes-and-screenshot-acceptance.md +20 -17
  58. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-project-token-consistency.md +7 -9
  59. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-stack-strategy.md +14 -10
  60. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/operational-processing-workflows.md +2 -0
  61. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/platform-mobile-patterns.md +1 -1
  62. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-lifecycle-acceptance-and-iteration.md +9 -6
  63. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-surface-patterns.md +3 -0
  64. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/source-map.md +37 -10
  65. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/tokens-and-components.md +7 -1
  66. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-audit.md +8 -5
  67. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-design-development.md +16 -5
  68. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/visual-craft.md +4 -2
  69. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/SKILL.md +5 -1
  70. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/architecture-playbook.md +1 -1
  71. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/audit-history-architecture.md +31 -0
  72. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/data-platform-architecture.md +1 -1
  73. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/event-driven-architecture.md +7 -4
  74. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/multi-tenant-isolation.md +2 -2
  75. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/notification-architecture.md +28 -0
  76. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/packaging-runtime-readiness.md +1 -1
  77. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/replay-comparison-architecture.md +28 -0
  78. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/workflow-state-architecture.md +39 -0
  79. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/SKILL.md +10 -7
  80. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/ai-service-wiring-patterns.md +8 -0
  81. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/audit-history-patterns.md +29 -0
  82. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/background-job-patterns.md +16 -0
  83. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/batch-and-artifact-patterns.md +25 -1
  84. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/notification-patterns.md +40 -0
  85. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/public-api-security-patterns.md +1 -1
  86. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/replay-comparison-patterns.md +30 -0
  87. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/state-machine-task-patterns.md +48 -0
  88. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/testing-and-quality-patterns.md +10 -1
  89. package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/SKILL.md +2 -0
  90. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +4 -4
  91. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/coverage-exhaustion-traps.md +45 -0
  92. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +142 -4
  93. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +21 -2
  94. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +11 -9
  95. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/firing-point-placement.md +8 -0
  96. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/parallel-stack-references-pattern.md +5 -4
  97. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/r0-leakage-audit.md +102 -0
  98. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +69 -0
  99. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +10 -0
  100. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/uiux-judgment-extraction.md +6 -6
  101. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +4 -3
  102. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +93 -2
  103. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-parallel-stack-parity.sh +119 -0
  104. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/extraction_review_gate.sh +22 -0
  105. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +49 -4
  106. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/obligation-ledger.py +2748 -0
  107. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/register-firing-path-resolution.rb +20 -5
  108. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/shared_git_surface_gate.py +1142 -0
  109. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_parallel_stack_parity.sh +183 -0
  110. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +19 -0
  111. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +41 -4
  112. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ci_checkout_ref_binding.sh +120 -0
  113. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_entrypoint_domain_scan_terms.sh +82 -8
  114. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_extraction_review_gate.sh +336 -0
  115. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_self_adjudication.sh +82 -10
  116. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger.sh +1416 -0
  117. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger_repo_audit.sh +57 -0
  118. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_register_firing_path_wiring.sh +141 -4
  119. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_pointer_integrity.sh +3 -1
  120. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_shared_git_surface_gate.sh +1696 -0
  121. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_delivery_contract.sh +2117 -0
  122. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_loading_budget.sh +316 -0
  123. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +1176 -0
  124. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_skill_cross_refs.sh +31 -1
  125. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate-skill.sh +9 -4
  126. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +980 -0
  127. package/dist/assets/marketplace/plugins/ccl-skills/skills/terminal-cli-dev/SKILL.md +9 -6
  128. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +11 -11
  129. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/client-runtime-test-matrices.md +10 -2
  130. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/fitness-functions.md +16 -0
  131. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/scenario-testing.md +1 -1
  132. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/test-code-authoring-patterns.md +16 -5
  133. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/SKILL.md +5 -3
  134. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/delivery-face-closeout.md +16 -6
  135. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/self-benchmark-baseline.md +37 -0
  136. package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/SKILL.md +7 -5
  137. package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/complex-workspace-patterns.md +1 -1
  138. package/dist/assets/release.json +275 -105
  139. package/package.json +1 -1
@@ -4,7 +4,6 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
- import ast
8
7
  import errno
9
8
  import hashlib
10
9
  import json
@@ -25,9 +24,48 @@ MAX_PACKET_BYTES = 200_000
25
24
  MAX_PLAN_BYTES = 32_000
26
25
  MAX_PROFILE_BYTES = 40_000
27
26
  MAX_RESULT_BYTES = 1_000_000
27
+ MAX_WORDING_ONLY_PROOF_BYTES = 16_000
28
+ MAX_BOUND_SOURCE_FILE_BYTES = 1_048_576
29
+ MAX_SELECTED_SKILL_PACKAGE_BYTES = 8_388_608
30
+ MAX_CONTROLLER_RUNTIME_BYTES = 8_388_608
28
31
  CONTROLLER_HEADROOM_SECONDS = 10
29
32
  MAX_CHALLENGE_BUDGET = 4
30
33
  SUPPORTED_CLIENTS = ("claude", "codex", "kimi", "opencode")
34
+
35
+ # ``--cwd`` is the one repository identity for base-mode review. Git otherwise
36
+ # lets ambient variables replace its refs, objects, index, or worktree despite
37
+ # every command also supplying ``-C``. Diff-specific hooks are excluded too:
38
+ # review packet construction must never execute an ambient helper.
39
+ GIT_REPOSITORY_ROUTING_ENV = (
40
+ "GIT_DIR",
41
+ "GIT_WORK_TREE",
42
+ "GIT_IMPLICIT_WORK_TREE",
43
+ "GIT_INDEX_FILE",
44
+ "GIT_COMMON_DIR",
45
+ "GIT_NAMESPACE",
46
+ "GIT_OBJECT_DIRECTORY",
47
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
48
+ "GIT_CEILING_DIRECTORIES",
49
+ "GIT_DISCOVERY_ACROSS_FILESYSTEM",
50
+ "GIT_GRAFT_FILE",
51
+ "GIT_SHALLOW_FILE",
52
+ "GIT_REPLACE_REF_BASE",
53
+ "GIT_PREFIX",
54
+ "GIT_INTERNAL_SUPER_PREFIX",
55
+ "GIT_QUARANTINE_PATH",
56
+ "GIT_EXTERNAL_DIFF",
57
+ "GIT_DIFF_OPTS",
58
+ "GIT_CONFIG",
59
+ "GIT_CONFIG_GLOBAL",
60
+ "GIT_CONFIG_SYSTEM",
61
+ "GIT_CONFIG_NOSYSTEM",
62
+ "GIT_CONFIG_COUNT",
63
+ "GIT_CONFIG_PARAMETERS",
64
+ )
65
+ GIT_REPOSITORY_ROUTING_ENV_PREFIXES = (
66
+ "GIT_CONFIG_KEY_",
67
+ "GIT_CONFIG_VALUE_",
68
+ )
31
69
  STATIC_CLIENT_FAMILIES = {
32
70
  "claude": "claude",
33
71
  "kimi": "moonshot",
@@ -270,9 +308,27 @@ def _hash_skill_package(skill_root: Path, skill_name: str) -> str:
270
308
 
271
309
  digest = hashlib.sha256()
272
310
  digest.update(b"selected-skill-v1\0")
311
+ package_bytes = 0
273
312
  for selected_path in selected_paths:
274
313
  relative_name = selected_path.relative_to(skill_root).as_posix()
275
- selected_bytes = selected_path.read_bytes()
314
+ selected_bytes = read_bounded_regular_file(
315
+ selected_path,
316
+ label=f"selected skill file {skill_name}/{relative_name}",
317
+ maximum=MAX_BOUND_SOURCE_FILE_BYTES,
318
+ regular_error=(
319
+ f"selected skill file is not a single-link regular file: {skill_name}/{relative_name}"
320
+ ),
321
+ oversized_error=(
322
+ f"selected skill file exceeds {MAX_BOUND_SOURCE_FILE_BYTES} bytes: {skill_name}/{relative_name}"
323
+ ),
324
+ reason_code="local_tool_failure",
325
+ )
326
+ package_bytes += len(selected_bytes)
327
+ if package_bytes > MAX_SELECTED_SKILL_PACKAGE_BYTES:
328
+ raise GateError(
329
+ f"selected skill package exceeds {MAX_SELECTED_SKILL_PACKAGE_BYTES} bytes: {skill_name}",
330
+ "local_tool_failure",
331
+ )
276
332
  digest.update(relative_name.encode())
277
333
  digest.update(b"\0")
278
334
  digest.update(len(selected_bytes).to_bytes(8, "big"))
@@ -386,6 +442,8 @@ CONTROLLER_OWNED_FIELDS = {
386
442
  "review_context_sha256",
387
443
  "review_controller_sha256",
388
444
  "review_profile_sha256",
445
+ "wording_only_proof_sha256",
446
+ "wording_only_scope",
389
447
  "reviewed_concerns",
390
448
  "reviewed_skills",
391
449
  "risk_tags",
@@ -540,11 +598,13 @@ def run(
540
598
  *,
541
599
  timeout_seconds: int,
542
600
  timeout_reason_code: str = "gate_timeout",
601
+ environment: dict[str, str] | None = None,
543
602
  ) -> subprocess.CompletedProcess[bytes]:
544
603
  try:
545
604
  process = subprocess.Popen(
546
605
  command,
547
606
  cwd=cwd,
607
+ env=environment,
548
608
  stdout=subprocess.PIPE,
549
609
  stderr=subprocess.PIPE,
550
610
  start_new_session=True,
@@ -599,6 +659,35 @@ def run(
599
659
  return subprocess.CompletedProcess(command, process.returncode, stdout, stderr)
600
660
 
601
661
 
662
+ def git_environment() -> dict[str, str]:
663
+ environment = os.environ.copy()
664
+ for key in GIT_REPOSITORY_ROUTING_ENV:
665
+ environment.pop(key, None)
666
+ for key in tuple(environment):
667
+ if key.startswith(GIT_REPOSITORY_ROUTING_ENV_PREFIXES):
668
+ environment.pop(key, None)
669
+ environment["GIT_CONFIG_GLOBAL"] = os.devnull
670
+ environment["GIT_CONFIG_SYSTEM"] = os.devnull
671
+ environment["GIT_CONFIG_NOSYSTEM"] = "1"
672
+ return environment
673
+
674
+
675
+ def git_command(repo: Path, args: list[str]) -> list[str]:
676
+ """Build a Git command with deterministic non-executable config."""
677
+
678
+ return [
679
+ "git",
680
+ "--no-pager",
681
+ "-c",
682
+ "core.fsmonitor=false",
683
+ "-c",
684
+ f"core.attributesFile={os.devnull}",
685
+ "-C",
686
+ str(repo),
687
+ *args,
688
+ ]
689
+
690
+
602
691
  def remaining_gate_seconds(deadline: float) -> int:
603
692
  return max(0, math.floor(deadline - time.monotonic()))
604
693
 
@@ -679,8 +768,9 @@ def git_output(
679
768
  deadline: float,
680
769
  ) -> bytes:
681
770
  result = run(
682
- ["git", "-C", str(repo), *args],
771
+ git_command(repo, args),
683
772
  timeout_seconds=remaining_preflight_seconds(deadline),
773
+ environment=git_environment(),
684
774
  )
685
775
  accepted = ok_codes or {0}
686
776
  if result.returncode not in accepted:
@@ -700,6 +790,239 @@ def validate_paths(paths: list[str]) -> list[str]:
700
790
  return validated
701
791
 
702
792
 
793
+ def read_bounded_regular_file(
794
+ path_value: str | Path,
795
+ *,
796
+ label: str,
797
+ maximum: int,
798
+ regular_error: str,
799
+ oversized_error: str,
800
+ reason_code: str = "invalid_input",
801
+ root: Path | None = None,
802
+ metadata_out: list[os.stat_result] | None = None,
803
+ ) -> bytes:
804
+ """Read one pathname once, without following links or trusting its size.
805
+
806
+ Path predicates followed by ``Path.read_bytes()`` leave a check/use window in
807
+ which the pathname can be replaced with a symlink. Open first with
808
+ ``O_NOFOLLOW`` and make every type/link/size decision from that descriptor.
809
+ The read itself is capped at ``maximum + 1`` so a file that grows after
810
+ ``fstat`` cannot turn the gate into an unbounded reader.
811
+ """
812
+
813
+ if (
814
+ not hasattr(os, "O_NOFOLLOW")
815
+ or not hasattr(os, "O_NONBLOCK")
816
+ or (root is not None and not hasattr(os, "O_DIRECTORY"))
817
+ ):
818
+ raise GateError(
819
+ f"this platform cannot safely open {label} without following links or blocking on special files",
820
+ reason_code,
821
+ )
822
+ file_flags = (
823
+ os.O_RDONLY
824
+ | os.O_NOFOLLOW
825
+ | os.O_NONBLOCK
826
+ | getattr(os, "O_CLOEXEC", 0)
827
+ )
828
+ directory_flags = (
829
+ os.O_RDONLY
830
+ | getattr(os, "O_DIRECTORY", 0)
831
+ | os.O_NOFOLLOW
832
+ | getattr(os, "O_CLOEXEC", 0)
833
+ )
834
+ source = Path(path_value)
835
+ relative_parts: tuple[str, ...] | None = None
836
+ directory_fds: list[int] = []
837
+ fd = -1
838
+ try:
839
+ if root is None:
840
+ fd = os.open(source, file_flags)
841
+ else:
842
+ relative = PurePosixPath(str(path_value))
843
+ if (
844
+ relative.is_absolute()
845
+ or not relative.parts
846
+ or ".." in relative.parts
847
+ or "." in relative.parts
848
+ ):
849
+ raise GateError(regular_error, reason_code)
850
+ relative_parts = relative.parts
851
+ directory_fds.append(os.open(root, directory_flags))
852
+ for component in relative_parts[:-1]:
853
+ directory_fds.append(
854
+ os.open(component, directory_flags, dir_fd=directory_fds[-1])
855
+ )
856
+ fd = os.open(relative_parts[-1], file_flags, dir_fd=directory_fds[-1])
857
+ except GateError:
858
+ raise
859
+ except OSError as exc:
860
+ raise GateError(f"cannot read {label}: {exc}", reason_code) from exc
861
+
862
+ try:
863
+ metadata = os.fstat(fd)
864
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1:
865
+ raise GateError(regular_error, reason_code)
866
+ if metadata.st_size > maximum:
867
+ raise GateError(oversized_error, reason_code)
868
+
869
+ chunks: list[bytes] = []
870
+ remaining = maximum + 1
871
+ while remaining > 0:
872
+ chunk = os.read(fd, min(65_536, remaining))
873
+ if not chunk:
874
+ break
875
+ chunks.append(chunk)
876
+ remaining -= len(chunk)
877
+
878
+ final_metadata = os.fstat(fd)
879
+ if (
880
+ not stat.S_ISREG(final_metadata.st_mode)
881
+ or final_metadata.st_nlink != 1
882
+ or final_metadata.st_size != metadata.st_size
883
+ or final_metadata.st_mtime_ns != metadata.st_mtime_ns
884
+ or final_metadata.st_ctime_ns != metadata.st_ctime_ns
885
+ ):
886
+ raise GateError(
887
+ f"{label} changed while it was being read",
888
+ reason_code,
889
+ )
890
+
891
+ try:
892
+ if root is None:
893
+ current_metadata = source.lstat()
894
+ else:
895
+ assert relative_parts is not None
896
+ verification_fds = [os.open(root, directory_flags)]
897
+ try:
898
+ for component in relative_parts[:-1]:
899
+ verification_fds.append(
900
+ os.open(
901
+ component,
902
+ directory_flags,
903
+ dir_fd=verification_fds[-1],
904
+ )
905
+ )
906
+ current_metadata = os.stat(
907
+ relative_parts[-1],
908
+ dir_fd=verification_fds[-1],
909
+ follow_symlinks=False,
910
+ )
911
+ finally:
912
+ for verification_fd in reversed(verification_fds):
913
+ try:
914
+ os.close(verification_fd)
915
+ except OSError:
916
+ pass
917
+ except OSError as exc:
918
+ raise GateError(
919
+ f"{label} changed while it was being read", reason_code
920
+ ) from exc
921
+ if (
922
+ not stat.S_ISREG(current_metadata.st_mode)
923
+ or current_metadata.st_nlink != 1
924
+ or current_metadata.st_dev != metadata.st_dev
925
+ or current_metadata.st_ino != metadata.st_ino
926
+ or current_metadata.st_mode != final_metadata.st_mode
927
+ or current_metadata.st_size != final_metadata.st_size
928
+ or current_metadata.st_mtime_ns != final_metadata.st_mtime_ns
929
+ or current_metadata.st_ctime_ns != final_metadata.st_ctime_ns
930
+ ):
931
+ raise GateError(
932
+ f"{label} changed while it was being read",
933
+ reason_code,
934
+ )
935
+ if metadata_out is not None:
936
+ metadata_out.append(metadata)
937
+ except OSError as exc:
938
+ raise GateError(f"cannot read {label}: {exc}", reason_code) from exc
939
+ finally:
940
+ if fd >= 0:
941
+ try:
942
+ os.close(fd)
943
+ except OSError:
944
+ pass
945
+ for directory_fd in reversed(directory_fds):
946
+ try:
947
+ os.close(directory_fd)
948
+ except OSError:
949
+ pass
950
+
951
+ encoded = b"".join(chunks)
952
+ if len(encoded) > maximum:
953
+ raise GateError(oversized_error, reason_code)
954
+ return encoded
955
+
956
+
957
+ def decode_git_c_path(token: str, *, strict_utf8: bool = False) -> str | None:
958
+ """Decode one Git C-style path token without Python-only escape syntax."""
959
+
960
+ if not token.startswith('"'):
961
+ return token
962
+ if len(token) < 2 or not token.endswith('"'):
963
+ return None
964
+ body = token[1:-1]
965
+ decoded_parts: list[str] = []
966
+ octets = bytearray()
967
+ escapes = {
968
+ "a": "\a",
969
+ "b": "\b",
970
+ "t": "\t",
971
+ "n": "\n",
972
+ "v": "\v",
973
+ "f": "\f",
974
+ "r": "\r",
975
+ "\\": "\\",
976
+ '"': '"',
977
+ }
978
+
979
+ def flush_octets() -> bool:
980
+ if not octets:
981
+ return True
982
+ try:
983
+ decoded_parts.append(
984
+ bytes(octets).decode(
985
+ "utf-8", "strict" if strict_utf8 else "surrogateescape"
986
+ )
987
+ )
988
+ except UnicodeError:
989
+ return False
990
+ octets.clear()
991
+ return True
992
+
993
+ index = 0
994
+ while index < len(body):
995
+ character = body[index]
996
+ if character != "\\":
997
+ if not flush_octets() or 0xD800 <= ord(character) <= 0xDFFF:
998
+ return None
999
+ decoded_parts.append(character)
1000
+ index += 1
1001
+ continue
1002
+ if index + 1 >= len(body):
1003
+ return None
1004
+ escaped = body[index + 1]
1005
+ if escaped in "01234567":
1006
+ if (
1007
+ index + 4 > len(body)
1008
+ or any(value not in "01234567" for value in body[index + 1 : index + 4])
1009
+ ):
1010
+ return None
1011
+ octet = int(body[index + 1 : index + 4], 8)
1012
+ if octet > 0xFF:
1013
+ return None
1014
+ octets.append(octet)
1015
+ index += 4
1016
+ continue
1017
+ if not flush_octets() or escaped not in escapes:
1018
+ return None
1019
+ decoded_parts.append(escapes[escaped])
1020
+ index += 2
1021
+ if not flush_octets():
1022
+ return None
1023
+ return "".join(decoded_parts)
1024
+
1025
+
703
1026
  FILE_TYPE_OWNERS = {
704
1027
  ".dart": "app-cross-platform-dev",
705
1028
  ".cjs": "web-react-dev",
@@ -719,17 +1042,7 @@ def candidate_paths_from_packet(packet: bytes) -> list[str]:
719
1042
  """Extract bounded repository-relative paths from one frozen text diff."""
720
1043
 
721
1044
  def decode_git_path(token: str) -> str | None:
722
- if not token.startswith('"'):
723
- return token
724
- try:
725
- decoded = ast.literal_eval(token)
726
- except (SyntaxError, ValueError):
727
- return None
728
- if not isinstance(decoded, str):
729
- return None
730
- if all(ord(char) <= 0xFF for char in decoded):
731
- return decoded.encode("latin-1").decode("utf-8", "surrogateescape")
732
- return decoded
1045
+ return decode_git_c_path(token)
733
1046
 
734
1047
  def diff_header_paths(line: str) -> list[str]:
735
1048
  payload = line.removeprefix("diff --git ")
@@ -841,57 +1154,119 @@ def derive_owner_selection(
841
1154
  ]
842
1155
 
843
1156
 
1157
+ def _validate_untracked_path_text(value: str) -> None:
1158
+ try:
1159
+ value.encode("utf-8")
1160
+ except UnicodeEncodeError as exc:
1161
+ raise GateError(
1162
+ "base-mode review cannot bind a non-UTF-8 untracked path"
1163
+ ) from exc
1164
+ if any(
1165
+ unicodedata.category(character) in {"Cc", "Zl", "Zp"}
1166
+ for character in value
1167
+ ):
1168
+ raise GateError(
1169
+ "base-mode review cannot bind a control-character or Unicode "
1170
+ "line-separator untracked path"
1171
+ )
1172
+
1173
+
1174
+ def render_untracked_file(
1175
+ relative: str, encoded: bytes, metadata: os.stat_result
1176
+ ) -> bytes:
1177
+ """Render exact frozen text bytes as a deterministic new-file review diff."""
1178
+
1179
+ _validate_untracked_path_text(relative)
1180
+ if b"\0" in encoded:
1181
+ raise GateError(
1182
+ "base-mode review cannot bind a NUL-bearing untracked file as text"
1183
+ )
1184
+ try:
1185
+ encoded.decode("utf-8")
1186
+ except UnicodeDecodeError as exc:
1187
+ raise GateError(
1188
+ "base-mode review cannot bind a non-UTF-8 untracked file as text"
1189
+ ) from exc
1190
+
1191
+ old_name = json.dumps(f"a/{relative}", ensure_ascii=False)
1192
+ new_name = json.dumps(f"b/{relative}", ensure_ascii=False)
1193
+ digest = hashlib.sha256(encoded).hexdigest()
1194
+ git_mode = "100755" if metadata.st_mode & 0o111 else "100644"
1195
+ # bytes.splitlines also breaks on lone CR/VT/FF, which would misrender the
1196
+ # frozen bytes as extra additions; a unified diff line ends on LF only.
1197
+ if encoded:
1198
+ segments = encoded.split(b"\n")
1199
+ if encoded.endswith(b"\n"):
1200
+ segments.pop()
1201
+ lines = [segment + b"\n" for segment in segments]
1202
+ else:
1203
+ tail = segments.pop()
1204
+ lines = [segment + b"\n" for segment in segments]
1205
+ lines.append(tail)
1206
+ else:
1207
+ lines = []
1208
+ rendered = bytearray(
1209
+ (
1210
+ f"diff --git {old_name} {new_name}\n"
1211
+ f"new file mode {git_mode}\n"
1212
+ "--- /dev/null\n"
1213
+ f"+++ {new_name}\n"
1214
+ f"@@ -0,0 +1,{len(lines)} @@ exact-untracked-file "
1215
+ f"bytes={len(encoded)} sha256={digest}\n"
1216
+ ).encode("utf-8")
1217
+ )
1218
+ for line in lines:
1219
+ rendered.extend(b"+" + line)
1220
+ if not line.endswith(b"\n"):
1221
+ rendered.extend(b"\n\\n")
1222
+ return bytes(rendered)
1223
+
1224
+
844
1225
  def untracked_packet(repo: Path, paths: list[str], deadline: float) -> bytes:
845
1226
  command = ["ls-files", "--others", "--exclude-standard", "-z"]
846
1227
  if paths:
847
1228
  command.extend(["--", *paths])
848
1229
  raw = git_output(repo, command, deadline=deadline)
849
1230
  chunks: list[bytes] = []
1231
+ rendered_bytes = 0
850
1232
  for encoded in raw.split(b"\0"):
851
1233
  if not encoded:
852
1234
  continue
853
1235
  relative = encoded.decode("utf-8", "surrogateescape")
854
- candidate = repo / relative
855
- try:
856
- metadata = candidate.lstat()
857
- resolved = candidate.resolve(strict=True)
858
- except OSError:
859
- chunks.append(
860
- f"Untracked path skipped; cannot resolve: {relative}\n".encode()
861
- )
862
- continue
863
- try:
864
- resolved.relative_to(repo)
865
- except ValueError:
866
- chunks.append(
867
- f"Untracked path skipped; resolves outside repository: {relative}\n".encode()
868
- )
869
- continue
870
- if stat.S_ISLNK(metadata.st_mode):
871
- chunks.append(
872
- f"Untracked symlink skipped for review safety: {relative}\n".encode()
873
- )
874
- continue
875
- if not stat.S_ISREG(metadata.st_mode):
876
- chunks.append(f"Untracked non-file path skipped: {relative}\n".encode())
877
- continue
878
- if metadata.st_nlink > 1:
879
- chunks.append(
880
- f"Untracked hardlink skipped for review safety: {relative}\n".encode()
881
- )
882
- continue
883
- diff = git_output(
884
- repo,
885
- ["diff", "--no-color", "--no-index", "--", "/dev/null", relative],
886
- {0, 1},
887
- deadline=deadline,
888
- )
889
- if diff:
890
- chunks.append(diff.rstrip(b"\n") + b"\n")
891
- else:
892
- chunks.append(
893
- f"Untracked file not shown as text diff: {relative}\n".encode()
1236
+ _validate_untracked_path_text(relative)
1237
+ candidate_path = PurePosixPath(relative)
1238
+ if (
1239
+ not relative
1240
+ or candidate_path.is_absolute()
1241
+ or ".." in candidate_path.parts
1242
+ or "." in candidate_path.parts
1243
+ ):
1244
+ raise GateError("git returned an invalid untracked candidate path")
1245
+ candidate_metadata: list[os.stat_result] = []
1246
+ candidate_bytes = read_bounded_regular_file(
1247
+ relative,
1248
+ root=repo,
1249
+ label="untracked candidate",
1250
+ maximum=MAX_PACKET_BYTES,
1251
+ regular_error=(
1252
+ "base-mode review requires every untracked candidate to be a single-link regular file"
1253
+ ),
1254
+ oversized_error=(
1255
+ f"untracked candidate exceeds {MAX_PACKET_BYTES} bytes"
1256
+ ),
1257
+ metadata_out=candidate_metadata,
1258
+ )
1259
+ if len(candidate_metadata) != 1:
1260
+ raise GateError("untracked candidate metadata binding failed")
1261
+ rendered = render_untracked_file(
1262
+ relative, candidate_bytes, candidate_metadata[0]
1263
+ )
1264
+ rendered_bytes += len(rendered)
1265
+ if rendered_bytes > MAX_PACKET_BYTES:
1266
+ raise GateError(
1267
+ f"untracked review packet exceeds {MAX_PACKET_BYTES} bytes"
894
1268
  )
1269
+ chunks.append(rendered)
895
1270
  return b"".join(chunks)
896
1271
 
897
1272
 
@@ -907,42 +1282,66 @@ def freeze_packet(
907
1282
  if args.diff_file:
908
1283
  if args.base or args.paths:
909
1284
  raise GateError("--diff-file cannot be combined with --base or --paths")
910
- source = Path(args.diff_file)
911
- if not source.is_file() or source.is_symlink():
912
- raise GateError(
913
- "--diff-file must name a readable regular file, not a symlink"
914
- )
915
- if source.stat().st_nlink > 1:
916
- raise GateError("--diff-file hardlinks are rejected for review safety")
917
- try:
918
- packet = source.read_bytes()
919
- except OSError as exc:
920
- raise GateError(f"cannot read --diff-file: {exc}") from exc
1285
+ packet = read_bounded_regular_file(
1286
+ args.diff_file,
1287
+ label="--diff-file",
1288
+ maximum=MAX_PACKET_BYTES,
1289
+ regular_error=(
1290
+ "--diff-file must name a readable regular non-linked file"
1291
+ ),
1292
+ oversized_error=f"review packet exceeds {MAX_PACKET_BYTES} bytes",
1293
+ )
921
1294
  else:
922
1295
  if not args.base:
923
1296
  raise GateError("one of --base or --diff-file is required")
924
1297
  root_result = run(
925
- ["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
1298
+ git_command(cwd, ["rev-parse", "--show-toplevel"]),
926
1299
  timeout_seconds=remaining_preflight_seconds(deadline),
1300
+ environment=git_environment(),
927
1301
  )
928
1302
  if root_result.returncode != 0:
929
1303
  raise GateError("--cwd is not inside a git repository")
930
1304
  repo = Path(root_result.stdout.decode().strip()).resolve()
1305
+ # Repository-local config attacks (core.worktree decoys, executable
1306
+ # helpers) follow the pinned neutralization posture: git_command
1307
+ # disables the executable vectors per invocation and the fixtures
1308
+ # assert the true packet survives a hostile include. The containment
1309
+ # check below stays as the cheap invariant: whatever discovery
1310
+ # resolved must actually contain --cwd.
1311
+ cwd_real = Path(cwd).resolve()
1312
+ if repo != cwd_real and repo not in cwd_real.parents:
1313
+ raise GateError(
1314
+ "resolved repository root does not contain --cwd; refusing "
1315
+ "to freeze a packet from a redirected worktree"
1316
+ )
931
1317
  verify = run(
932
- [
933
- "git",
934
- "-C",
935
- str(repo),
936
- "rev-parse",
937
- "--verify",
938
- f"{args.base}^{{commit}}",
939
- ],
1318
+ git_command(
1319
+ repo,
1320
+ ["rev-parse", "--verify", f"{args.base}^{{commit}}"],
1321
+ ),
940
1322
  timeout_seconds=remaining_preflight_seconds(deadline),
1323
+ environment=git_environment(),
941
1324
  )
942
1325
  if verify.returncode != 0:
943
1326
  raise GateError(f"invalid base ref: {args.base}")
944
1327
  paths = validate_paths(args.paths)
945
- diff_args = ["diff", "--no-color", args.base]
1328
+ diff_args = [
1329
+ "diff",
1330
+ "--no-color",
1331
+ "--no-ext-diff",
1332
+ "--no-textconv",
1333
+ # In-tree .gitattributes can mark a changed file `-diff`, which
1334
+ # would collapse its hunks to a binary marker and hide the change
1335
+ # from the packet. --text forces content; a genuinely binary file
1336
+ # then fails the packet's NUL check instead of passing unseen.
1337
+ "--text",
1338
+ ]
1339
+ # A wording-only proof must establish where frontmatter ends from the
1340
+ # frozen packet itself. Full context starts each changed file at line
1341
+ # one; the ordinary packet-size ceiling remains the resource bound.
1342
+ if args.wording_only_proof_file:
1343
+ diff_args.append("--unified=1000000")
1344
+ diff_args.append(args.base)
946
1345
  if paths:
947
1346
  diff_args.extend(["--", *paths])
948
1347
  tracked = git_output(repo, diff_args, deadline=deadline)
@@ -978,16 +1377,21 @@ def freeze_packet(
978
1377
  )
979
1378
 
980
1379
 
981
- def verify_packet(path: Path, expected_hash: str) -> None:
982
- try:
983
- actual_hash = hashlib.sha256(path.read_bytes()).hexdigest()
984
- except OSError as exc:
985
- raise GateError(
986
- f"cannot re-read frozen review packet: {exc}", "binding_mismatch"
987
- ) from exc
1380
+ def verify_packet(
1381
+ path: Path, expected_hash: str, *, label: str, maximum: int
1382
+ ) -> None:
1383
+ encoded = read_bounded_regular_file(
1384
+ path,
1385
+ label=label,
1386
+ maximum=maximum,
1387
+ regular_error=f"{label} is not a single-link regular file",
1388
+ oversized_error=f"{label} exceeds {maximum} bytes",
1389
+ reason_code="binding_mismatch",
1390
+ )
1391
+ actual_hash = hashlib.sha256(encoded).hexdigest()
988
1392
  if actual_hash != expected_hash:
989
1393
  raise GateError(
990
- "frozen review packet changed during provider execution", "binding_mismatch"
1394
+ f"{label} changed during provider execution", "binding_mismatch"
991
1395
  )
992
1396
 
993
1397
 
@@ -1001,26 +1405,26 @@ def _bounded_text(
1001
1405
  raise GateError(
1002
1406
  f"{field} must contain between {minimum} and {maximum} characters"
1003
1407
  )
1408
+ try:
1409
+ normalized.encode("utf-8")
1410
+ except UnicodeEncodeError as exc:
1411
+ raise GateError(
1412
+ f"{field} contains a lone surrogate at character {exc.start}"
1413
+ ) from exc
1004
1414
  return normalized
1005
1415
 
1006
1416
 
1007
1417
  def _load_review_plan(path_value: str) -> dict[str, Any]:
1008
- source = Path(path_value)
1009
- try:
1010
- metadata = source.lstat()
1011
- except OSError as exc:
1012
- raise GateError(f"cannot read --review-plan-file: {exc}") from exc
1013
- if (
1014
- not stat.S_ISREG(metadata.st_mode)
1015
- or source.is_symlink()
1016
- or metadata.st_nlink > 1
1017
- ):
1018
- raise GateError("--review-plan-file must be a regular non-linked file")
1019
- if metadata.st_size > MAX_PLAN_BYTES:
1020
- raise GateError("--review-plan-file exceeds 32000 bytes")
1418
+ encoded = read_bounded_regular_file(
1419
+ path_value,
1420
+ label="--review-plan-file",
1421
+ maximum=MAX_PLAN_BYTES,
1422
+ regular_error="--review-plan-file must be a regular non-linked file",
1423
+ oversized_error="--review-plan-file exceeds 32000 bytes",
1424
+ )
1021
1425
  try:
1022
- payload = json.loads(source.read_text(encoding="utf-8"))
1023
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
1426
+ payload = json.loads(encoded.decode("utf-8"))
1427
+ except (UnicodeError, json.JSONDecodeError) as exc:
1024
1428
  raise GateError(f"--review-plan-file is not valid UTF-8 JSON: {exc}") from exc
1025
1429
  required = {"intent", "acceptance", "self_review", "evidence"}
1026
1430
  if not isinstance(payload, dict) or set(payload) != required:
@@ -1127,6 +1531,12 @@ def _canonical_review_scope(profile: dict[str, Any]) -> dict[str, Any]:
1127
1531
  "review_depth": profile["review_depth"],
1128
1532
  "risk_tags": profile["risk_tags"],
1129
1533
  "challenge_budget": profile["challenge_budget"],
1534
+ "wording_only_proof_sha256": profile["wording_only_proof_sha256"],
1535
+ "wording_only_scope_sha256": (
1536
+ _canonical_digest(profile["wording_only_scope"])
1537
+ if profile["wording_only_scope"] is not None
1538
+ else None
1539
+ ),
1130
1540
  }
1131
1541
  if _review_scope_digest(scope) != profile["review_scope_sha256"]:
1132
1542
  raise GateError(
@@ -1145,26 +1555,22 @@ def _load_prior_review_result(
1145
1555
  "--prior-review-result-file must be absolute", "review_chain_invalid"
1146
1556
  )
1147
1557
  try:
1148
- metadata = source.lstat()
1149
- except OSError as exc:
1150
- raise GateError(
1151
- f"cannot read prior review result {expected_index}: {exc}",
1152
- "review_chain_invalid",
1153
- ) from exc
1154
- if (
1155
- not stat.S_ISREG(metadata.st_mode)
1156
- or source.is_symlink()
1157
- or metadata.st_nlink > 1
1158
- or metadata.st_size > MAX_RESULT_BYTES
1159
- ):
1160
- raise GateError(
1161
- f"prior review result {expected_index} is not a bounded regular JSON file",
1162
- "review_chain_invalid",
1558
+ encoded = read_bounded_regular_file(
1559
+ source,
1560
+ label=f"prior review result {expected_index}",
1561
+ maximum=MAX_RESULT_BYTES,
1562
+ regular_error=(
1563
+ f"prior review result {expected_index} is not a bounded regular JSON file"
1564
+ ),
1565
+ oversized_error=(
1566
+ f"prior review result {expected_index} is not bounded JSON"
1567
+ ),
1568
+ reason_code="review_chain_invalid",
1163
1569
  )
1164
- try:
1165
- encoded = source.read_bytes()
1166
1570
  payload = json.loads(encoded.decode("utf-8"))
1167
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
1571
+ except GateError:
1572
+ raise
1573
+ except (UnicodeError, json.JSONDecodeError) as exc:
1168
1574
  raise GateError(
1169
1575
  f"cannot read prior review result {expected_index}: {exc}",
1170
1576
  "review_chain_invalid",
@@ -1192,9 +1598,924 @@ def _stable_binding_matches(
1192
1598
  )
1193
1599
 
1194
1600
 
1601
+ def _wording_only_error(reason: str) -> None:
1602
+ raise GateError(reason, "wording_only_proof_invalid")
1603
+
1604
+
1605
+ def _decode_canonical_git_path(token: str) -> str:
1606
+ if not token:
1607
+ _wording_only_error("wording-only packet carries an empty path")
1608
+ if not token.startswith('"'):
1609
+ if any(character.isspace() for character in token):
1610
+ _wording_only_error("wording-only packet carries an unquoted path")
1611
+ return token
1612
+ decoded = decode_git_c_path(token, strict_utf8=True)
1613
+ if decoded is None:
1614
+ _wording_only_error("wording-only packet carries an invalid Git-quoted path")
1615
+ return decoded
1616
+
1617
+
1618
+ def _split_canonical_git_paths(payload: str, expected: int) -> list[str]:
1619
+ tokens: list[str] = []
1620
+ index = 0
1621
+ while index < len(payload):
1622
+ while index < len(payload) and payload[index].isspace():
1623
+ index += 1
1624
+ if index >= len(payload):
1625
+ break
1626
+ start = index
1627
+ if payload[index] == '"':
1628
+ index += 1
1629
+ escaped = False
1630
+ while index < len(payload):
1631
+ character = payload[index]
1632
+ index += 1
1633
+ if escaped:
1634
+ escaped = False
1635
+ elif character == "\\":
1636
+ escaped = True
1637
+ elif character == '"':
1638
+ break
1639
+ else:
1640
+ _wording_only_error(
1641
+ "wording-only packet carries an unterminated quoted path"
1642
+ )
1643
+ else:
1644
+ while index < len(payload) and not payload[index].isspace():
1645
+ index += 1
1646
+ tokens.append(_decode_canonical_git_path(payload[start:index]))
1647
+ if len(tokens) > expected:
1648
+ break
1649
+ if len(tokens) != expected or payload[index:].strip():
1650
+ _wording_only_error("wording-only packet carries a non-canonical path header")
1651
+ return tokens
1652
+
1653
+
1654
+ def _validate_wording_markdown_path(value: str) -> None:
1655
+ path = PurePosixPath(value)
1656
+ if (
1657
+ not value
1658
+ or len(value.encode("utf-8")) > 1000
1659
+ or any(unicodedata.category(character)[0] == "C" for character in value)
1660
+ or path.is_absolute()
1661
+ or str(path) != value
1662
+ or ".." in path.parts
1663
+ or len(path.parts) < 3
1664
+ or path.parts[0] != "skills"
1665
+ or path.suffix != ".md"
1666
+ or "scripts" in {part.casefold() for part in path.parts}
1667
+ ):
1668
+ _wording_only_error(
1669
+ "wording-only scope must contain only Markdown prose inside exactly one shared skill package"
1670
+ )
1671
+
1672
+
1673
+ def _advance_frontmatter_state(
1674
+ state: str | None, line_number: int, content: str
1675
+ ) -> tuple[str | None, bool]:
1676
+ marker = content.strip() == "---"
1677
+ if state == "unseen" and line_number == 1:
1678
+ return ("inside", False) if marker else ("outside", True)
1679
+ if state == "inside":
1680
+ return ("outside", False) if marker else ("inside", False)
1681
+ if state == "outside":
1682
+ return "outside", True
1683
+ return None, False
1684
+
1685
+
1686
+ def _validate_changed_markdown_line(
1687
+ state: str | None, line_number: int, content: str
1688
+ ) -> str | None:
1689
+ next_state, outside_frontmatter = _advance_frontmatter_state(
1690
+ state, line_number, content
1691
+ )
1692
+ if (
1693
+ not outside_frontmatter
1694
+ or content.strip() == "---"
1695
+ or content.lstrip().casefold().startswith("description:")
1696
+ ):
1697
+ _wording_only_error(
1698
+ "wording-only scope cannot change Markdown frontmatter or description metadata"
1699
+ )
1700
+ return next_state
1701
+
1702
+
1703
+ def _leading_markdown_indent(content: str) -> tuple[int, int]:
1704
+ columns = 0
1705
+ index = 0
1706
+ while index < len(content) and content[index] in {" ", "\t"}:
1707
+ columns += 1 if content[index] == " " else 4 - (columns % 4)
1708
+ index += 1
1709
+ return columns, index
1710
+
1711
+
1712
+ def _strip_markdown_indent(
1713
+ content: str, required: int, *, initial_columns: int = 0
1714
+ ) -> str | None:
1715
+ """Remove at least ``required`` Markdown columns, retaining tab overshoot."""
1716
+
1717
+ columns = initial_columns
1718
+ target = initial_columns + required
1719
+ index = 0
1720
+ while (
1721
+ columns < target
1722
+ and index < len(content)
1723
+ and content[index] in {" ", "\t"}
1724
+ ):
1725
+ columns += 1 if content[index] == " " else 4 - (columns % 4)
1726
+ index += 1
1727
+ if columns < target:
1728
+ return None
1729
+ return (" " * (columns - target)) + content[index:]
1730
+
1731
+
1732
+ def _split_markdown_list_item(content: str) -> tuple[int, str] | None:
1733
+ """Return a list item's content indent and first-line content when evident."""
1734
+
1735
+ leading_columns, marker_start = _leading_markdown_indent(content)
1736
+ if leading_columns > 3 or marker_start >= len(content):
1737
+ return None
1738
+ marker_end = marker_start
1739
+ if content[marker_start] in {"-", "+", "*"}:
1740
+ marker_end += 1
1741
+ else:
1742
+ while marker_end < len(content) and content[marker_end].isdigit():
1743
+ marker_end += 1
1744
+ digit_count = marker_end - marker_start
1745
+ if (
1746
+ not 1 <= digit_count <= 9
1747
+ or marker_end >= len(content)
1748
+ or content[marker_end] not in {".", ")"}
1749
+ ):
1750
+ return None
1751
+ marker_end += 1
1752
+
1753
+ marker_end_column = leading_columns + marker_end - marker_start
1754
+ if marker_end == len(content):
1755
+ return marker_end_column + 1, ""
1756
+ if content[marker_end] not in {" ", "\t"}:
1757
+ return None
1758
+
1759
+ content_start = marker_end
1760
+ content_column = marker_end_column
1761
+ while content_start < len(content) and content[content_start] in {" ", "\t"}:
1762
+ content_column += (
1763
+ 1
1764
+ if content[content_start] == " "
1765
+ else 4 - (content_column % 4)
1766
+ )
1767
+ content_start += 1
1768
+ padding = content_column - marker_end_column
1769
+ if content_start == len(content):
1770
+ return marker_end_column + 1, ""
1771
+ if padding <= 4:
1772
+ return content_column, content[content_start:]
1773
+
1774
+ # CommonMark uses one column of list padding when five or more were given;
1775
+ # the remainder stays indentation in the item's content.
1776
+ remainder = _strip_markdown_indent(
1777
+ content[marker_end:], 1, initial_columns=marker_end_column
1778
+ )
1779
+ assert remainder is not None
1780
+ return marker_end_column + 1, remainder
1781
+
1782
+
1783
+ def _fence_marker(content: str) -> tuple[str, int] | None:
1784
+ leading_columns, marker_start = _leading_markdown_indent(content)
1785
+ if leading_columns > 3 or marker_start >= len(content):
1786
+ return None
1787
+ stripped = content[marker_start:]
1788
+ marker = stripped[0]
1789
+ if marker not in {"`", "~"}:
1790
+ return None
1791
+ marker_count = len(stripped) - len(stripped.lstrip(marker))
1792
+ if marker_count < 3:
1793
+ return None
1794
+ info = stripped[marker_count:]
1795
+ if marker == "`" and "`" in info:
1796
+ return None
1797
+ return marker, marker_count
1798
+
1799
+
1800
+ def _advance_fenced_code_state(
1801
+ state: tuple[str | None, int, int] | None, content: str
1802
+ ) -> tuple[tuple[str | None, int, int] | None, bool]:
1803
+ """Track fenced and indented code plus their narrow list context."""
1804
+
1805
+ marker = state[0] if state is not None else None
1806
+ minimum = state[1] if state is not None else 0
1807
+ container_indent = state[2] if state is not None else 0
1808
+ relative = content
1809
+ if container_indent:
1810
+ if not content.strip(" \t"):
1811
+ return state, marker is not None
1812
+ relative = _strip_markdown_indent(content, container_indent)
1813
+ if relative is None:
1814
+ # A non-indented line leaves the list container. An unclosed fence
1815
+ # ends with that container; the current line is ordinary top-level
1816
+ # Markdown and must be classified again from scratch.
1817
+ state = None
1818
+ marker = None
1819
+ minimum = 0
1820
+ container_indent = 0
1821
+ relative = content
1822
+
1823
+ if marker is not None:
1824
+ closing = _fence_marker(relative)
1825
+ if (
1826
+ closing is not None
1827
+ and closing[0] == marker
1828
+ and closing[1] >= minimum
1829
+ ):
1830
+ _, marker_start = _leading_markdown_indent(relative)
1831
+ stripped = relative[marker_start:]
1832
+ if not stripped[closing[1] :].strip(" \t"):
1833
+ return (
1834
+ (None, 0, container_indent) if container_indent else None,
1835
+ True,
1836
+ )
1837
+ return state, True
1838
+
1839
+ leading_columns, _ = _leading_markdown_indent(relative)
1840
+ if relative.strip(" \t") and leading_columns >= 4:
1841
+ return state, True
1842
+
1843
+ opening = _fence_marker(relative)
1844
+ if opening is not None:
1845
+ return (opening[0], opening[1], container_indent), True
1846
+
1847
+ list_item = _split_markdown_list_item(relative)
1848
+ if list_item is not None:
1849
+ item_indent, item_content = list_item
1850
+ nested_indent = container_indent + item_indent
1851
+ opening = _fence_marker(item_content)
1852
+ if opening is not None:
1853
+ return (opening[0], opening[1], nested_indent), True
1854
+ item_columns, _ = _leading_markdown_indent(item_content)
1855
+ if item_content.strip(" \t") and item_columns >= 4:
1856
+ return (None, 0, nested_indent), True
1857
+ return (None, 0, nested_indent), False
1858
+
1859
+ return state, False
1860
+
1861
+
1862
+ _HTML_CODE_CONTAINER = re.compile(
1863
+ r"<\s*(/?)\s*(pre|code|script|style|textarea)\b[^>]*>", re.IGNORECASE
1864
+ )
1865
+ _PLAIN_PROSE_BLOCK_PREFIX = re.compile(
1866
+ r"(?:#{1,6}(?:[ \t]|$)|>(?:[ \t]|$)|(?:[-+*]|\d{1,9}[.)])(?:[ \t]|$))"
1867
+ )
1868
+ _PLAIN_PROSE_FORBIDDEN = frozenset("\\`*_{}[]<>()|~")
1869
+
1870
+
1871
+ def _advance_html_code_state(
1872
+ state: tuple[str, ...], content: str
1873
+ ) -> tuple[tuple[str, ...], bool]:
1874
+ """Conservatively track raw HTML pre/code containers."""
1875
+
1876
+ containers = list(state)
1877
+ touches_code = bool(containers)
1878
+ for match in _HTML_CODE_CONTAINER.finditer(content):
1879
+ touches_code = True
1880
+ closing, raw_name = match.groups()
1881
+ name = raw_name.casefold()
1882
+ if closing:
1883
+ for index in range(len(containers) - 1, -1, -1):
1884
+ if containers[index] == name:
1885
+ del containers[index:]
1886
+ break
1887
+ elif not match.group(0).rstrip().endswith("/>"):
1888
+ containers.append(name)
1889
+ return tuple(containers), touches_code
1890
+
1891
+
1892
+ # Question marks flip a statement's assertive polarity ("must reject." ->
1893
+ # "must reject?") and quote marks delimit literals ("passed", not "findings"
1894
+ # vs "passed, not findings"), so neither may be added, removed, or MOVED by a
1895
+ # punctuation proof. Exclamation marks keep the assertion and stay in the
1896
+ # certifiable set (pinned by fixtures). Marks are compared with their anchor —
1897
+ # the count of non-punctuation characters before them — so a mark sliding
1898
+ # along an otherwise identical skeleton is rejected too.
1899
+ _MEANING_PUNCTUATION = frozenset(
1900
+ "??¿؟՞⸮⁇⁈⁉‽﹖\u037e" # trailing escape: Greek erotimatiko, not ";"
1901
+ "\"'“”‘’«»‹›„‚「」『』"
1902
+ )
1903
+
1904
+
1905
+ def _anchored_meaning_marks(line: str) -> tuple[tuple[str, int], ...]:
1906
+ marks: list[tuple[str, int]] = []
1907
+ anchor = 0
1908
+ for character in line:
1909
+ if unicodedata.category(character).startswith("P"):
1910
+ if character in _MEANING_PUNCTUATION:
1911
+ marks.append((character, anchor))
1912
+ else:
1913
+ anchor += 1
1914
+ return tuple(marks)
1915
+
1916
+
1917
+ def _numeric_token_signature(line: str) -> tuple[str, ...]:
1918
+ """Digit runs with their interior punctuation ("5.5", "2,000").
1919
+
1920
+ Stripping punctuation alone would certify "5.5" -> "55" or "5.5" -> "5,5"
1921
+ as punctuation-only; numeric tokens must survive the edit byte-for-byte."""
1922
+
1923
+ tokens: list[str] = []
1924
+ current: list[str] = []
1925
+
1926
+ def flush() -> None:
1927
+ run = "".join(current)
1928
+ current.clear()
1929
+ trimmed = run.strip("".join(
1930
+ character
1931
+ for character in run
1932
+ if unicodedata.category(character).startswith("P")
1933
+ ))
1934
+ if any(
1935
+ unicodedata.category(character).startswith("N")
1936
+ for character in trimmed
1937
+ ):
1938
+ tokens.append(trimmed)
1939
+
1940
+ for character in line:
1941
+ if unicodedata.category(character)[0] in {"N", "P"}:
1942
+ current.append(character)
1943
+ elif current:
1944
+ flush()
1945
+ if current:
1946
+ flush()
1947
+ return tuple(tokens)
1948
+
1949
+
1950
+ def _plain_prose_punctuation_change(
1951
+ change_blocks: list[tuple[list[str], list[str]]],
1952
+ ) -> bool:
1953
+ """Return whether every edit is punctuation-only on plain prose lines."""
1954
+
1955
+ for removed, added in change_blocks:
1956
+ if not removed or len(removed) != len(added):
1957
+ return False
1958
+ for old_line, new_line in zip(removed, added, strict=True):
1959
+ if old_line == new_line:
1960
+ return False
1961
+ for line in (old_line, new_line):
1962
+ if (
1963
+ not line
1964
+ or line[0] in {" ", "\t"}
1965
+ or _PLAIN_PROSE_BLOCK_PREFIX.match(line)
1966
+ or any(character in _PLAIN_PROSE_FORBIDDEN for character in line)
1967
+ or any(
1968
+ unicodedata.category(character).startswith("C")
1969
+ for character in line
1970
+ )
1971
+ or not any(
1972
+ unicodedata.category(character)[0] in {"L", "M", "N"}
1973
+ for character in line
1974
+ )
1975
+ ):
1976
+ return False
1977
+ old_skeleton = "".join(
1978
+ character
1979
+ for character in old_line
1980
+ if not unicodedata.category(character).startswith("P")
1981
+ )
1982
+ new_skeleton = "".join(
1983
+ character
1984
+ for character in new_line
1985
+ if not unicodedata.category(character).startswith("P")
1986
+ )
1987
+ if old_skeleton != new_skeleton:
1988
+ return False
1989
+ if _numeric_token_signature(old_line) != _numeric_token_signature(
1990
+ new_line
1991
+ ):
1992
+ return False
1993
+ # "must reject." -> "must reject?" strips to identical skeletons,
1994
+ # but question and quote marks carry meaning: adding, removing,
1995
+ # or moving one is not certifiable as punctuation-only.
1996
+ if _anchored_meaning_marks(old_line) != _anchored_meaning_marks(
1997
+ new_line
1998
+ ):
1999
+ return False
2000
+ return True
2001
+
2002
+
2003
+ def _parse_canonical_wording_packet(packet: bytes) -> dict[str, Any]:
2004
+ try:
2005
+ text = packet.decode("utf-8")
2006
+ except UnicodeError as exc:
2007
+ raise GateError(
2008
+ "wording-only packet must be canonical UTF-8 text",
2009
+ "wording_only_proof_invalid",
2010
+ ) from exc
2011
+ if not text.endswith("\n") or "\r" in text or "\0" in text:
2012
+ _wording_only_error(
2013
+ "wording-only packet must be a canonical LF-terminated unified diff"
2014
+ )
2015
+ lines = text.splitlines(keepends=True)
2016
+ index = 0
2017
+ changed_files: list[str] = []
2018
+ changed_lines: list[str] = []
2019
+ change_blocks: list[tuple[list[str], list[str]]] = []
2020
+ punctuation_touches_code_container = False
2021
+ seen_paths: set[str] = set()
2022
+ index_pattern = re.compile(
2023
+ r"index [0-9a-f]{4,64}\.\.[0-9a-f]{4,64} (?:100644|100755)\n\Z"
2024
+ )
2025
+ hunk_pattern = re.compile(
2026
+ r"@@ -(\d{1,6})(?:,(\d{1,6}))? \+(\d{1,6})(?:,(\d{1,6}))? @@(?:[^\r\n]*)\n\Z"
2027
+ )
2028
+
2029
+ while index < len(lines):
2030
+ header = lines[index]
2031
+ if not header.startswith("diff --git "):
2032
+ _wording_only_error(
2033
+ "wording-only proof requires a canonical git unified diff"
2034
+ )
2035
+ left_header, right_header = _split_canonical_git_paths(
2036
+ header[len("diff --git ") : -1], 2
2037
+ )
2038
+ if (
2039
+ not left_header.startswith("a/")
2040
+ or not right_header.startswith("b/")
2041
+ or left_header[2:] != right_header[2:]
2042
+ ):
2043
+ _wording_only_error(
2044
+ "wording-only proof does not permit add, delete, rename, or cross-path diffs"
2045
+ )
2046
+ path = left_header[2:]
2047
+ _validate_wording_markdown_path(path)
2048
+ if path in seen_paths:
2049
+ _wording_only_error(
2050
+ "wording-only packet repeats a changed path in multiple diff sections"
2051
+ )
2052
+ seen_paths.add(path)
2053
+ changed_files.append(path)
2054
+ index += 1
2055
+
2056
+ if index >= len(lines) or not index_pattern.fullmatch(lines[index]):
2057
+ _wording_only_error(
2058
+ "wording-only proof requires the canonical git index header"
2059
+ )
2060
+ index += 1
2061
+ if index >= len(lines) or not lines[index].startswith("--- "):
2062
+ _wording_only_error(
2063
+ "wording-only proof requires a canonical old-file header"
2064
+ )
2065
+ old_path = _split_canonical_git_paths(lines[index][4:-1], 1)[0]
2066
+ index += 1
2067
+ if index >= len(lines) or not lines[index].startswith("+++ "):
2068
+ _wording_only_error(
2069
+ "wording-only proof requires a canonical new-file header"
2070
+ )
2071
+ new_path = _split_canonical_git_paths(lines[index][4:-1], 1)[0]
2072
+ index += 1
2073
+ if old_path != f"a/{path}" or new_path != f"b/{path}":
2074
+ _wording_only_error(
2075
+ "wording-only file headers do not bind the diff header path"
2076
+ )
2077
+
2078
+ old_frontmatter: str | None = None
2079
+ new_frontmatter: str | None = None
2080
+ old_fence: tuple[str | None, int, int] | None = None
2081
+ new_fence: tuple[str | None, int, int] | None = None
2082
+ old_html_code: tuple[str, ...] = ()
2083
+ new_html_code: tuple[str, ...] = ()
2084
+ old_fence_known = True
2085
+ new_fence_known = True
2086
+ old_next_line: int | None = None
2087
+ new_next_line: int | None = None
2088
+ section_changed = False
2089
+ section_hunks = 0
2090
+ while index < len(lines) and lines[index].startswith("@@ "):
2091
+ match = hunk_pattern.fullmatch(lines[index])
2092
+ if match is None:
2093
+ _wording_only_error(
2094
+ "wording-only proof requires canonical unified hunk headers"
2095
+ )
2096
+ old_start = int(match.group(1))
2097
+ old_count = int(match.group(2)) if match.group(2) is not None else 1
2098
+ new_start = int(match.group(3))
2099
+ new_count = int(match.group(4)) if match.group(4) is not None else 1
2100
+ if old_count < 0 or new_count < 0:
2101
+ _wording_only_error("wording-only hunk carries an invalid line count")
2102
+ if old_next_line is None:
2103
+ old_frontmatter = "unseen" if old_start == 1 else None
2104
+ old_fence_known = old_start == 1
2105
+ elif old_start < old_next_line:
2106
+ _wording_only_error("wording-only hunks overlap or run backwards")
2107
+ elif old_start > old_next_line:
2108
+ if old_frontmatter == "inside":
2109
+ old_frontmatter = None
2110
+ old_fence_known = False
2111
+ if new_next_line is None:
2112
+ new_frontmatter = "unseen" if new_start == 1 else None
2113
+ new_fence_known = new_start == 1
2114
+ elif new_start < new_next_line:
2115
+ _wording_only_error("wording-only hunks overlap or run backwards")
2116
+ elif new_start > new_next_line:
2117
+ if new_frontmatter == "inside":
2118
+ new_frontmatter = None
2119
+ new_fence_known = False
2120
+ index += 1
2121
+ section_hunks += 1
2122
+ old_line = old_start
2123
+ new_line = new_start
2124
+ old_seen = 0
2125
+ new_seen = 0
2126
+ block_old: list[str] = []
2127
+ block_new: list[str] = []
2128
+
2129
+ def flush_block() -> None:
2130
+ nonlocal block_old, block_new
2131
+ if block_old or block_new:
2132
+ change_blocks.append((block_old, block_new))
2133
+ block_old = []
2134
+ block_new = []
2135
+
2136
+ while old_seen < old_count or new_seen < new_count:
2137
+ if index >= len(lines):
2138
+ _wording_only_error("wording-only hunk is truncated")
2139
+ raw_line = lines[index]
2140
+ if not raw_line.endswith("\n") or not raw_line:
2141
+ _wording_only_error(
2142
+ "wording-only hunk contains a non-canonical body line"
2143
+ )
2144
+ prefix = raw_line[0]
2145
+ content = raw_line[1:-1]
2146
+ if prefix == " ":
2147
+ flush_block()
2148
+ if old_seen >= old_count or new_seen >= new_count:
2149
+ _wording_only_error("wording-only hunk exceeds its line counts")
2150
+ old_frontmatter, _ = _advance_frontmatter_state(
2151
+ old_frontmatter, old_line, content
2152
+ )
2153
+ new_frontmatter, _ = _advance_frontmatter_state(
2154
+ new_frontmatter, new_line, content
2155
+ )
2156
+ if old_fence_known:
2157
+ old_fence, _ = _advance_fenced_code_state(old_fence, content)
2158
+ old_html_code, _ = _advance_html_code_state(
2159
+ old_html_code, content
2160
+ )
2161
+ if new_fence_known:
2162
+ new_fence, _ = _advance_fenced_code_state(new_fence, content)
2163
+ new_html_code, _ = _advance_html_code_state(
2164
+ new_html_code, content
2165
+ )
2166
+ if old_frontmatter != new_frontmatter:
2167
+ _wording_only_error(
2168
+ "wording-only scope cannot shift or activate Markdown frontmatter"
2169
+ )
2170
+ old_line += 1
2171
+ new_line += 1
2172
+ old_seen += 1
2173
+ new_seen += 1
2174
+ elif prefix == "-":
2175
+ if block_new:
2176
+ _wording_only_error(
2177
+ "wording-only hunk interleaves additions and removals"
2178
+ )
2179
+ if old_seen >= old_count:
2180
+ _wording_only_error("wording-only hunk exceeds its old-line count")
2181
+ old_frontmatter = _validate_changed_markdown_line(
2182
+ old_frontmatter, old_line, content
2183
+ )
2184
+ if not old_fence_known:
2185
+ punctuation_touches_code_container = True
2186
+ else:
2187
+ next_fence, touches_fenced_code = _advance_fenced_code_state(
2188
+ old_fence, content
2189
+ )
2190
+ next_html_code, touches_html_code = _advance_html_code_state(
2191
+ old_html_code, content
2192
+ )
2193
+ if touches_fenced_code or touches_html_code:
2194
+ punctuation_touches_code_container = True
2195
+ old_fence = next_fence
2196
+ old_html_code = next_html_code
2197
+ block_old.append(content)
2198
+ changed_lines.append(content)
2199
+ old_line += 1
2200
+ old_seen += 1
2201
+ section_changed = True
2202
+ elif prefix == "+":
2203
+ if new_seen >= new_count:
2204
+ _wording_only_error("wording-only hunk exceeds its new-line count")
2205
+ new_frontmatter = _validate_changed_markdown_line(
2206
+ new_frontmatter, new_line, content
2207
+ )
2208
+ if not new_fence_known:
2209
+ punctuation_touches_code_container = True
2210
+ else:
2211
+ next_fence, touches_fenced_code = _advance_fenced_code_state(
2212
+ new_fence, content
2213
+ )
2214
+ next_html_code, touches_html_code = _advance_html_code_state(
2215
+ new_html_code, content
2216
+ )
2217
+ if touches_fenced_code or touches_html_code:
2218
+ punctuation_touches_code_container = True
2219
+ new_fence = next_fence
2220
+ new_html_code = next_html_code
2221
+ block_new.append(content)
2222
+ changed_lines.append(content)
2223
+ new_line += 1
2224
+ new_seen += 1
2225
+ section_changed = True
2226
+ else:
2227
+ _wording_only_error(
2228
+ "wording-only hunk contains compact, binary, or custom diff data"
2229
+ )
2230
+ index += 1
2231
+ flush_block()
2232
+ old_next_line = old_start + old_count
2233
+ new_next_line = new_start + new_count
2234
+
2235
+ if section_hunks == 0 or not section_changed:
2236
+ _wording_only_error(
2237
+ "wording-only diff section must contain at least one canonical changed hunk"
2238
+ )
2239
+ if old_frontmatter != "outside" or new_frontmatter != "outside":
2240
+ _wording_only_error(
2241
+ "wording-only packet must prove unchanged frontmatter boundaries from line one"
2242
+ )
2243
+ if index < len(lines) and not lines[index].startswith("diff --git "):
2244
+ _wording_only_error(
2245
+ "wording-only packet contains non-canonical diff metadata"
2246
+ )
2247
+
2248
+ if not changed_files or not changed_lines:
2249
+ _wording_only_error("wording-only packet contains no changed prose")
2250
+ return {
2251
+ "changed_files": sorted(changed_files),
2252
+ "changed_lines": changed_lines,
2253
+ "change_blocks": change_blocks,
2254
+ "punctuation_touches_code_container": punctuation_touches_code_container,
2255
+ }
2256
+
2257
+
2258
+ def _load_wording_only_proof(
2259
+ path_value: str,
2260
+ packet_path: Path,
2261
+ packet_hash: str,
2262
+ candidate_paths: list[str],
2263
+ review_root: Path,
2264
+ ) -> tuple[str, dict[str, Any]]:
2265
+ source = Path(path_value)
2266
+ if not source.is_absolute():
2267
+ _wording_only_error("--wording-only-proof-file must be absolute")
2268
+ try:
2269
+ encoded = read_bounded_regular_file(
2270
+ source,
2271
+ label="wording-only proof",
2272
+ maximum=MAX_WORDING_ONLY_PROOF_BYTES,
2273
+ regular_error="wording-only proof must be a single-link regular JSON file",
2274
+ oversized_error=(
2275
+ f"wording-only proof exceeds {MAX_WORDING_ONLY_PROOF_BYTES} bytes"
2276
+ ),
2277
+ reason_code="wording_only_proof_invalid",
2278
+ )
2279
+ packet = read_bounded_regular_file(
2280
+ packet_path,
2281
+ label="frozen wording-only review packet",
2282
+ maximum=MAX_PACKET_BYTES,
2283
+ regular_error="frozen wording-only review packet is not a regular file",
2284
+ oversized_error=f"review packet exceeds {MAX_PACKET_BYTES} bytes",
2285
+ reason_code="wording_only_proof_invalid",
2286
+ )
2287
+
2288
+ def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
2289
+ result: dict[str, Any] = {}
2290
+ for key, value in pairs:
2291
+ if key in result:
2292
+ raise ValueError(f"duplicate key: {key}")
2293
+ result[key] = value
2294
+ return result
2295
+
2296
+ def reject_constant(value: str) -> None:
2297
+ raise ValueError(f"non-finite JSON value: {value}")
2298
+
2299
+ proof = json.loads(
2300
+ encoded.decode("utf-8"),
2301
+ object_pairs_hook=reject_duplicate_keys,
2302
+ parse_constant=reject_constant,
2303
+ )
2304
+ except GateError:
2305
+ raise
2306
+ except (UnicodeError, json.JSONDecodeError, ValueError) as exc:
2307
+ raise GateError(
2308
+ f"cannot read wording-only proof: {exc}",
2309
+ "wording_only_proof_invalid",
2310
+ ) from exc
2311
+ if hashlib.sha256(packet).hexdigest() != packet_hash:
2312
+ _wording_only_error("frozen wording-only packet binding changed")
2313
+ if not isinstance(proof, dict) or set(proof) != {
2314
+ "schema_version",
2315
+ "candidate_sha256",
2316
+ "check",
2317
+ }:
2318
+ _wording_only_error("wording-only proof has an invalid top-level schema")
2319
+ if (
2320
+ type(proof["schema_version"]) is not int
2321
+ or proof["schema_version"] != 1
2322
+ or proof["candidate_sha256"] != packet_hash
2323
+ ):
2324
+ _wording_only_error("wording-only proof does not bind the exact candidate")
2325
+
2326
+ check = proof["check"]
2327
+ if not isinstance(check, dict):
2328
+ _wording_only_error("wording-only proof check has an invalid schema")
2329
+ kind = check.get("kind")
2330
+ if kind == "markdown-punctuation-only":
2331
+ if check != {"kind": kind}:
2332
+ _wording_only_error(
2333
+ "wording-only punctuation proof carries an invalid check schema"
2334
+ )
2335
+ elif kind == "markdown-token-replacement":
2336
+ if set(check) != {
2337
+ "kind",
2338
+ "old_token",
2339
+ "new_token",
2340
+ "expected_count",
2341
+ }:
2342
+ _wording_only_error(
2343
+ "wording-only token proof carries an invalid check schema"
2344
+ )
2345
+ old_token = check["old_token"]
2346
+ new_token = check["new_token"]
2347
+ expected_count = check["expected_count"]
2348
+
2349
+ def valid_token(value: Any) -> bool:
2350
+ if not isinstance(value, str) or not 1 <= len(value) <= 80:
2351
+ return False
2352
+ try:
2353
+ encoded_value = value.encode("utf-8")
2354
+ except UnicodeError:
2355
+ return False
2356
+ return (
2357
+ len(encoded_value) <= 240
2358
+ and unicodedata.category(value[0])[0] in {"L", "N"}
2359
+ and unicodedata.category(value[-1])[0] in {"L", "N"}
2360
+ and all(
2361
+ unicodedata.category(character)[0] in {"L", "N"}
2362
+ or character in "._'-"
2363
+ for character in value
2364
+ )
2365
+ )
2366
+
2367
+ if (
2368
+ not valid_token(old_token)
2369
+ or not valid_token(new_token)
2370
+ or old_token == new_token
2371
+ or not isinstance(expected_count, int)
2372
+ or isinstance(expected_count, bool)
2373
+ or not 1 <= expected_count <= 100
2374
+ ):
2375
+ _wording_only_error(
2376
+ "wording-only token proof must name two bounded distinct tokens and an exact count"
2377
+ )
2378
+ else:
2379
+ _wording_only_error("wording-only proof names an unsupported fixed check")
2380
+
2381
+ parsed = _parse_canonical_wording_packet(packet)
2382
+ if parsed["changed_files"] != sorted(candidate_paths):
2383
+ _wording_only_error(
2384
+ "wording-only parser paths do not reproduce the frozen candidate paths"
2385
+ )
2386
+ skill_names = {
2387
+ PurePosixPath(path).parts[1] for path in parsed["changed_files"]
2388
+ }
2389
+ if len(skill_names) != 1:
2390
+ _wording_only_error(
2391
+ "wording-only proof cannot waive the required challenge for a multi-skill candidate"
2392
+ )
2393
+ skill_name = next(iter(skill_names))
2394
+ read_bounded_regular_file(
2395
+ (PurePosixPath("skills") / skill_name / "SKILL.md").as_posix(),
2396
+ root=review_root,
2397
+ label="wording-only target skill entrypoint",
2398
+ maximum=MAX_BOUND_SOURCE_FILE_BYTES,
2399
+ regular_error=(
2400
+ "wording-only proof must target one existing non-linked skill package"
2401
+ ),
2402
+ oversized_error=(
2403
+ "wording-only target skill entrypoint exceeds "
2404
+ f"{MAX_BOUND_SOURCE_FILE_BYTES} bytes"
2405
+ ),
2406
+ reason_code="wording_only_proof_invalid",
2407
+ )
2408
+ for changed_file in parsed["changed_files"]:
2409
+ read_bounded_regular_file(
2410
+ changed_file,
2411
+ root=review_root,
2412
+ label="wording-only changed Markdown file",
2413
+ maximum=MAX_BOUND_SOURCE_FILE_BYTES,
2414
+ regular_error=(
2415
+ "wording-only proof must change existing single-link regular Markdown files"
2416
+ ),
2417
+ oversized_error=(
2418
+ "wording-only changed Markdown file exceeds "
2419
+ f"{MAX_BOUND_SOURCE_FILE_BYTES} bytes"
2420
+ ),
2421
+ reason_code="wording_only_proof_invalid",
2422
+ )
2423
+ replacement_count = 0
2424
+ if kind == "markdown-punctuation-only":
2425
+ if parsed["punctuation_touches_code_container"]:
2426
+ _wording_only_error(
2427
+ "punctuation-only scope cannot change a Markdown code container"
2428
+ )
2429
+ if not _plain_prose_punctuation_change(parsed["change_blocks"]):
2430
+ _wording_only_error(
2431
+ "punctuation-only scope must change only punctuation on plain prose lines"
2432
+ )
2433
+ else:
2434
+ if parsed["punctuation_touches_code_container"]:
2435
+ _wording_only_error(
2436
+ "token-replacement scope cannot change a Markdown code container"
2437
+ )
2438
+ token_pattern = re.compile(re.escape(check["old_token"]))
2439
+
2440
+ def token_continues(character: str) -> bool:
2441
+ category = unicodedata.category(character)
2442
+ return (
2443
+ category[0] in {"L", "M", "N"}
2444
+ or category == "Pc"
2445
+ or character in {"\u200c", "\u200d"}
2446
+ )
2447
+
2448
+ for removed, added in parsed["change_blocks"]:
2449
+ if not removed or len(removed) != len(added):
2450
+ _wording_only_error(
2451
+ "token-replacement scope contains an add, delete, or unequal change block"
2452
+ )
2453
+ for old_line, new_line in zip(removed, added, strict=True):
2454
+ matches = [
2455
+ match
2456
+ for match in token_pattern.finditer(old_line)
2457
+ if (
2458
+ match.start() == 0
2459
+ or not token_continues(old_line[match.start() - 1])
2460
+ )
2461
+ and (
2462
+ match.end() == len(old_line)
2463
+ or not token_continues(old_line[match.end()])
2464
+ )
2465
+ ]
2466
+ cursor = 0
2467
+ rebuilt_parts: list[str] = []
2468
+ for match in matches:
2469
+ rebuilt_parts.extend(
2470
+ (old_line[cursor : match.start()], check["new_token"])
2471
+ )
2472
+ cursor = match.end()
2473
+ rebuilt_parts.append(old_line[cursor:])
2474
+ replaced_line = "".join(rebuilt_parts)
2475
+ if not matches or replaced_line != new_line:
2476
+ _wording_only_error(
2477
+ "token-replacement scope changes bytes outside the named token"
2478
+ )
2479
+ replacement_count += len(matches)
2480
+ if replacement_count != check["expected_count"]:
2481
+ _wording_only_error(
2482
+ "token-replacement scope does not reproduce its exact replacement count"
2483
+ )
2484
+
2485
+ result_core = {
2486
+ "status": "passed",
2487
+ "changed_files": parsed["changed_files"],
2488
+ "changed_line_count": len(parsed["changed_lines"]),
2489
+ "replacement_count": replacement_count,
2490
+ }
2491
+ scope_sha256 = _canonical_digest(
2492
+ {
2493
+ "candidate_sha256": packet_hash,
2494
+ "check": check,
2495
+ **result_core,
2496
+ }
2497
+ )
2498
+ normalized_scope = {
2499
+ "status": "passed",
2500
+ "check_kind": kind,
2501
+ "changed_files": parsed["changed_files"],
2502
+ "changed_line_count": len(parsed["changed_lines"]),
2503
+ "replacement_count": replacement_count,
2504
+ "scope_sha256": scope_sha256,
2505
+ }
2506
+ if kind == "markdown-token-replacement":
2507
+ normalized_scope.update(
2508
+ old_token=check["old_token"],
2509
+ new_token=check["new_token"],
2510
+ expected_count=check["expected_count"],
2511
+ )
2512
+ return hashlib.sha256(encoded).hexdigest(), normalized_scope
2513
+
2514
+
1195
2515
  def freeze_review_profile(
1196
2516
  args: argparse.Namespace,
1197
2517
  script_dir: Path,
2518
+ packet_path: Path,
1198
2519
  packet_hash: str,
1199
2520
  candidate_paths: list[str],
1200
2521
  ) -> tuple[Path, str, dict[str, Any], bool]:
@@ -1373,8 +2694,33 @@ def freeze_review_profile(
1373
2694
  raise GateError(
1374
2695
  "--challenge-budget must be between 0 and 4 so the initial review plus challenges never exceeds five Agent-autonomous external rounds"
1375
2696
  )
2697
+ wording_only_proof_sha256: str | None = None
2698
+ wording_only_scope: dict[str, Any] | None = None
2699
+ if args.wording_only_proof_file:
2700
+ if (
2701
+ args.mode != "review"
2702
+ or challenge_budget != 0
2703
+ or args.review_chain_id is not None
2704
+ or args.autonomous_review_index is not None
2705
+ or args.prior_review_result_file
2706
+ ):
2707
+ _wording_only_error(
2708
+ "--wording-only-proof-file is valid only for one untracked review with challenge budget 0"
2709
+ )
2710
+ wording_only_proof_sha256, wording_only_scope = _load_wording_only_proof(
2711
+ args.wording_only_proof_file,
2712
+ packet_path,
2713
+ packet_hash,
2714
+ candidate_paths,
2715
+ Path(args.cwd),
2716
+ )
1376
2717
  if review_depth == "release" and challenge_budget == 0:
1377
- raise GateError("release and high-risk review require at least one challenge")
2718
+ if wording_only_scope is None:
2719
+ raise GateError("release and high-risk review require at least one challenge")
2720
+ if wording_only_scope["check_kind"] != "markdown-punctuation-only":
2721
+ _wording_only_error(
2722
+ "release and high-risk challenge waiver requires a markdown-punctuation-only proof"
2723
+ )
1378
2724
  challenge_focus = (
1379
2725
  _bounded_text(args.focus, "challenge focus", maximum=1000)
1380
2726
  if args.focus
@@ -1460,14 +2806,27 @@ def freeze_review_profile(
1460
2806
  )
1461
2807
  controller_digest = hashlib.sha256()
1462
2808
  controller_digest.update(b"code-review-runtime-v1\0")
2809
+ controller_runtime_bytes = 0
1463
2810
  for controller_path in controller_paths:
1464
2811
  controller_name = controller_path.relative_to(script_dir).as_posix()
1465
- if controller_path.is_symlink():
2812
+ controller_bytes = read_bounded_regular_file(
2813
+ controller_path,
2814
+ label=f"review controller runtime {controller_name}",
2815
+ maximum=MAX_BOUND_SOURCE_FILE_BYTES,
2816
+ regular_error=(
2817
+ f"review controller runtime is not a single-link regular file: {controller_name}"
2818
+ ),
2819
+ oversized_error=(
2820
+ f"review controller runtime exceeds {MAX_BOUND_SOURCE_FILE_BYTES} bytes: {controller_name}"
2821
+ ),
2822
+ reason_code="local_tool_failure",
2823
+ )
2824
+ controller_runtime_bytes += len(controller_bytes)
2825
+ if controller_runtime_bytes > MAX_CONTROLLER_RUNTIME_BYTES:
1466
2826
  raise GateError(
1467
- f"review controller runtime is a symlink: {controller_name}",
2827
+ f"review controller runtime exceeds {MAX_CONTROLLER_RUNTIME_BYTES} aggregate bytes",
1468
2828
  "local_tool_failure",
1469
2829
  )
1470
- controller_bytes = controller_path.read_bytes()
1471
2830
  controller_digest.update(controller_name.encode())
1472
2831
  controller_digest.update(b"\0")
1473
2832
  controller_digest.update(len(controller_bytes).to_bytes(8, "big"))
@@ -1526,6 +2885,12 @@ def freeze_review_profile(
1526
2885
  "review_depth": review_depth,
1527
2886
  "risk_tags": risk_tags,
1528
2887
  "challenge_budget": challenge_budget,
2888
+ "wording_only_proof_sha256": wording_only_proof_sha256,
2889
+ "wording_only_scope_sha256": (
2890
+ _canonical_digest(wording_only_scope)
2891
+ if wording_only_scope is not None
2892
+ else None
2893
+ ),
1529
2894
  }
1530
2895
  review_scope_sha256 = _review_scope_digest(review_scope)
1531
2896
  if review_scope_sha256 is None:
@@ -1554,6 +2919,8 @@ def freeze_review_profile(
1554
2919
  "selected_skills": selected_skills,
1555
2920
  "selected_skills_sha256": selected_skills_sha256,
1556
2921
  "review_controller_sha256": review_controller_sha256,
2922
+ "wording_only_proof_sha256": wording_only_proof_sha256,
2923
+ "wording_only_scope": wording_only_scope,
1557
2924
  "self_review": self_review,
1558
2925
  "evidence": evidence,
1559
2926
  }
@@ -1702,7 +3069,7 @@ def freeze_review_profile(
1702
3069
  if args.mode == "complete":
1703
3070
  self_review_satisfied_triggers.append("before_completion_claim")
1704
3071
 
1705
- reviewer_concern_pairs = concern_pairs
3072
+ reviewer_concern_pairs = list(concern_pairs)
1706
3073
  # Stated here, beside the construction, so the normalizer is told rather than
1707
3074
  # reconstructing it from the frozen profile.
1708
3075
  synthetic_slot = builds_synthetic_slot(args.mode, high_risk)
@@ -1720,6 +3087,13 @@ def freeze_review_profile(
1720
3087
  "Test high-risk bypasses and containment evidence.",
1721
3088
  )
1722
3089
  )
3090
+ if wording_only_scope is not None:
3091
+ reviewer_concern_pairs.append(
3092
+ (
3093
+ "wording_only_boundary",
3094
+ "Independently confirm this exact candidate is only the controller-proved punctuation-only edit or named typo-token replacement, and changes no trigger, scope, routing, validation, acceptance, rule, threshold, boundary, frontmatter, description, or other meaning.",
3095
+ )
3096
+ )
1723
3097
 
1724
3098
  profile = {
1725
3099
  "schema_version": 1,
@@ -1755,6 +3129,8 @@ def freeze_review_profile(
1755
3129
  "selected_skills_sha256": selected_skills_sha256,
1756
3130
  "review_context_sha256": review_context_sha256,
1757
3131
  "review_controller_sha256": review_controller_sha256,
3132
+ "wording_only_proof_sha256": wording_only_proof_sha256,
3133
+ "wording_only_scope": wording_only_scope,
1758
3134
  "self_review": self_review,
1759
3135
  "evidence": evidence,
1760
3136
  }
@@ -1993,6 +3369,8 @@ def composite_base(
1993
3369
  "review_context_sha256": profile["review_context_sha256"],
1994
3370
  "review_controller_sha256": profile["review_controller_sha256"],
1995
3371
  "review_profile_sha256": profile_hash,
3372
+ "wording_only_proof_sha256": profile["wording_only_proof_sha256"],
3373
+ "wording_only_scope": profile["wording_only_scope"],
1996
3374
  "owner_selection_source": profile["owner_selection_source"],
1997
3375
  "owner_selection_evidence": profile["owner_selection_evidence"],
1998
3376
  "review_plan_source": profile["review_plan_source"],
@@ -2164,6 +3542,10 @@ def validate_completion_checkpoint(
2164
3542
  profile["selected_skills_sha256"],
2165
3543
  )
2166
3544
  or prior.get("completion_gated") is not True
3545
+ or "wording_only_proof_sha256" not in prior
3546
+ or prior["wording_only_proof_sha256"] is not None
3547
+ or "wording_only_scope" not in prior
3548
+ or prior["wording_only_scope"] is not None
2167
3549
  or not (final_round_checkpoint or early_challenge_checkpoint)
2168
3550
  ):
2169
3551
  raise GateError(
@@ -2298,6 +3680,7 @@ def build_parser() -> argparse.ArgumentParser:
2298
3680
  parser.add_argument("--paths", nargs="*", default=[])
2299
3681
  parser.add_argument("--implementer-family", required=True)
2300
3682
  parser.add_argument("--review-plan-file")
3683
+ parser.add_argument("--wording-only-proof-file")
2301
3684
  parser.add_argument("--stage", choices=tuple(STAGE_CONCERNS), default="build")
2302
3685
  parser.add_argument("--risk-tag", action="append", default=[])
2303
3686
  parser.add_argument("--challenge-budget", type=int)
@@ -2327,8 +3710,8 @@ def main(argv: list[str] | None = None) -> int:
2327
3710
  gate_deadline: float | None = None
2328
3711
  try:
2329
3712
  args = build_parser().parse_args(argv)
2330
- if args.timeout < 5 or args.timeout > 600:
2331
- raise GateError("--timeout must be between 5 and 600 seconds")
3713
+ if args.timeout < 5 or args.timeout > 1200:
3714
+ raise GateError("--timeout must be between 5 and 1200 seconds")
2332
3715
  if args.total_timeout < 5 or args.total_timeout > 3600:
2333
3716
  raise GateError("--total-timeout must be between 5 and 3600 seconds")
2334
3717
  gate_deadline = gate_started_at + args.total_timeout
@@ -2343,15 +3726,23 @@ def main(argv: list[str] | None = None) -> int:
2343
3726
  freeze_packet(args, gate_deadline)
2344
3727
  )
2345
3728
  profile_path, profile_hash, profile, synthetic_slot = freeze_review_profile(
2346
- args, script_dir, packet_hash, candidate_paths
3729
+ args, script_dir, packet_path, packet_hash, candidate_paths
2347
3730
  )
2348
3731
  # The rendered review profile (intent/acceptance/evidence/self-review
2349
3732
  # text) egresses to the non-Claude reviewer alongside the diff packet, so
2350
3733
  # a secret pasted into an explicit plan must gate egress too. Union the
2351
3734
  # profile's scan with the packet's before any egress decision.
3735
+ frozen_profile_bytes = read_bounded_regular_file(
3736
+ profile_path,
3737
+ label="frozen review profile",
3738
+ maximum=MAX_PROFILE_BYTES,
3739
+ regular_error="frozen review profile is not a single-link regular file",
3740
+ oversized_error=f"frozen review profile exceeds {MAX_PROFILE_BYTES} bytes",
3741
+ reason_code="binding_mismatch",
3742
+ )
2352
3743
  egress_secret_categories = sorted(
2353
3744
  set(egress_secret_categories)
2354
- | set(scan_egress_secrets(profile_path.read_bytes()))
3745
+ | set(scan_egress_secrets(frozen_profile_bytes))
2355
3746
  )
2356
3747
  except GateError as exc:
2357
3748
  for temporary_path in (profile_path, packet_path):
@@ -2505,8 +3896,18 @@ def main(argv: list[str] | None = None) -> int:
2505
3896
  continue
2506
3897
 
2507
3898
  try:
2508
- verify_packet(packet_path, packet_hash)
2509
- verify_packet(profile_path, profile_hash)
3899
+ verify_packet(
3900
+ packet_path,
3901
+ packet_hash,
3902
+ label="frozen review packet",
3903
+ maximum=MAX_PACKET_BYTES,
3904
+ )
3905
+ verify_packet(
3906
+ profile_path,
3907
+ profile_hash,
3908
+ label="frozen review profile",
3909
+ maximum=MAX_PROFILE_BYTES,
3910
+ )
2510
3911
  assert gate_deadline is not None
2511
3912
  remaining_seconds = remaining_gate_seconds(gate_deadline)
2512
3913
  wrapper_timeout = invocation_timeout_seconds(
@@ -2535,8 +3936,18 @@ def main(argv: list[str] | None = None) -> int:
2535
3936
  ),
2536
3937
  timeout_reason_code="timeout",
2537
3938
  )
2538
- verify_packet(packet_path, packet_hash)
2539
- verify_packet(profile_path, profile_hash)
3939
+ verify_packet(
3940
+ packet_path,
3941
+ packet_hash,
3942
+ label="frozen review packet",
3943
+ maximum=MAX_PACKET_BYTES,
3944
+ )
3945
+ verify_packet(
3946
+ profile_path,
3947
+ profile_hash,
3948
+ label="frozen review profile",
3949
+ maximum=MAX_PROFILE_BYTES,
3950
+ )
2540
3951
  except GateError as exc:
2541
3952
  record_attempt(
2542
3953
  result,