@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.
- package/README.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/remind-unverified-cli-flag.sh +309 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_remind_unverified_cli_flag.sh +483 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/SKILL.md +10 -8
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/mobile-quality-release.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/SKILL.md +16 -17
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/client-routing.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +195 -7
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/timeout-auth-and-capabilities.md +3 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/claude_review.sh +13 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/codex_review.sh +9 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/kimi_review.sh +9 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/normalize_review_timeout.sh +22 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/opencode_review.sh +9 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +1540 -129
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_claude_review_probe.sh +8 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_client_compat.py +76 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +1858 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_update_review_plan_intent.sh +789 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/update_review_plan_intent.py +513 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/architecture-playbook.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/data-platform-architecture.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/event-driven-architecture.md +14 -11
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/multi-tenant-isolation.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/SKILL.md +5 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +2 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/SKILL.md +13 -11
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/SKILL.md +64 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/async-lifecycle-and-performance.md +72 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/runtime-and-project-contract.md +58 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/source-map.md +41 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/verification-diagnostics-and-security.md +63 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/sli-slo-design.md +25 -9
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/source-register.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/promotion-gate-and-review.md +16 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/secret-and-config-management.md +7 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/retry-timeout-circuit-breaker.md +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +8 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/design-routing-and-readiness.md +10 -14
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/verify-developer-experience.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/SKILL.md +135 -86
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/behavioral-aesthetic-logic.md +66 -80
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/delivery-contract.md +275 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-execution-checklist.md +88 -214
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-impl-naming-and-versioning.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-intake-and-acceptance.md +10 -8
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-system-source-of-truth.md +4 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/external-ui-ux-quality-benchmarks.md +112 -95
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/frontend-code-evidence-map.md +30 -21
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/interaction-design-patterns.md +22 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/layout-recipes-and-screenshot-acceptance.md +20 -17
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-project-token-consistency.md +7 -9
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-stack-strategy.md +14 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/operational-processing-workflows.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/platform-mobile-patterns.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-lifecycle-acceptance-and-iteration.md +9 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-surface-patterns.md +3 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/source-map.md +37 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/tokens-and-components.md +7 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-audit.md +8 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-design-development.md +16 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/visual-craft.md +4 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/SKILL.md +5 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/architecture-playbook.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/audit-history-architecture.md +31 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/data-platform-architecture.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/event-driven-architecture.md +7 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/multi-tenant-isolation.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/notification-architecture.md +28 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/packaging-runtime-readiness.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/replay-comparison-architecture.md +28 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/workflow-state-architecture.md +39 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/SKILL.md +10 -7
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/ai-service-wiring-patterns.md +8 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/audit-history-patterns.md +29 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/background-job-patterns.md +16 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/batch-and-artifact-patterns.md +25 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/notification-patterns.md +40 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/public-api-security-patterns.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/replay-comparison-patterns.md +30 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/state-machine-task-patterns.md +48 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/testing-and-quality-patterns.md +10 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/SKILL.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +4 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/coverage-exhaustion-traps.md +45 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +142 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +21 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +11 -9
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/firing-point-placement.md +8 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/parallel-stack-references-pattern.md +5 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/r0-leakage-audit.md +102 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +69 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/uiux-judgment-extraction.md +6 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +4 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +93 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-parallel-stack-parity.sh +119 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/extraction_review_gate.sh +22 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +49 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/obligation-ledger.py +2748 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/register-firing-path-resolution.rb +20 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/shared_git_surface_gate.py +1142 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_parallel_stack_parity.sh +183 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +19 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +41 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ci_checkout_ref_binding.sh +120 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_entrypoint_domain_scan_terms.sh +82 -8
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_extraction_review_gate.sh +336 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_self_adjudication.sh +82 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger.sh +1416 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger_repo_audit.sh +57 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_register_firing_path_wiring.sh +141 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_pointer_integrity.sh +3 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_shared_git_surface_gate.sh +1696 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_delivery_contract.sh +2117 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_loading_budget.sh +316 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +1176 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_skill_cross_refs.sh +31 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate-skill.sh +9 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +980 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/terminal-cli-dev/SKILL.md +9 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +11 -11
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/client-runtime-test-matrices.md +10 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/fitness-functions.md +16 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/scenario-testing.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/test-code-authoring-patterns.md +16 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/SKILL.md +5 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/delivery-face-closeout.md +16 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/self-benchmark-baseline.md +37 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/SKILL.md +7 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/complex-workspace-patterns.md +1 -1
- package/dist/assets/release.json +275 -105
- package/package.json +1 -1
package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
-
|
|
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
|
-
|
|
934
|
-
"-
|
|
935
|
-
|
|
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 = [
|
|
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(
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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(
|
|
1023
|
-
except (
|
|
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
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 >
|
|
2331
|
-
raise GateError("--timeout must be between 5 and
|
|
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(
|
|
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(
|
|
2509
|
-
|
|
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(
|
|
2539
|
-
|
|
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,
|