From ed0f5bd6827bba8cfef7948bb339b694dbfb62d5 Mon Sep 17 00:00:00 2001 From: Bruno Vicco Date: Sat, 18 Jul 2026 17:51:41 -0300 Subject: [PATCH] feat(loop): pin schemas v0.1.2 and verify vendor integrity --- .github/workflows/harness-quality.yml | 6 +- .../scripts/_vendor_loop_schemas/__init__.py | 55 +----- .../_vendor_loop_schemas/manifest.json | 29 ++++ .../scripts/_vendor_loop_schemas/models.py | 16 +- .../_vendor_loop_schemas/validate_contract.py | 20 +-- template/scripts/quality_gate.py | 4 + .../scripts/validate_loop_schema_vendor.py | 162 ++++++++++++++++++ tests/test_loop_schema_vendor.py | 49 ++++++ 8 files changed, 262 insertions(+), 79 deletions(-) create mode 100644 template/scripts/_vendor_loop_schemas/manifest.json create mode 100644 template/scripts/validate_loop_schema_vendor.py create mode 100644 tests/test_loop_schema_vendor.py diff --git a/.github/workflows/harness-quality.yml b/.github/workflows/harness-quality.yml index 02f4400..12dd3df 100644 --- a/.github/workflows/harness-quality.yml +++ b/.github/workflows/harness-quality.yml @@ -23,7 +23,7 @@ jobs: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" @@ -54,7 +54,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" @@ -88,7 +88,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" diff --git a/template/scripts/_vendor_loop_schemas/__init__.py b/template/scripts/_vendor_loop_schemas/__init__.py index df72b30..abfe520 100644 --- a/template/scripts/_vendor_loop_schemas/__init__.py +++ b/template/scripts/_vendor_loop_schemas/__init__.py @@ -1,51 +1,6 @@ # Vendored from brunovicco/engineering-loop-schemas -# @ 75a63eef269fd995128ab39c89e551fe58a27bf7 (v0.1.0, includes the UP037/RUF100 lint fixes: a -# per-file-ignore for the load-bearing quoted return annotations, and -# removal of a stray noqa that was itself flagged once RUF is enabled). -# No published tag exists yet as of this vendoring; pinned to the commit -# above. Do not edit by hand -- re-vendor from the source repository -# instead. See docs/LOOPS.md for how this fits into the Phase 0-1 -# report-only loop foundation. -# Adapted for vendoring: this directory is named `_vendor_loop_schemas` -# (not `loop_schemas`) so it does not itself match the `scripts/loop_*` -# out-of-scope-loop-path denylist in protect_sensitive_files.py; the -# `from loop_schemas...` imports were changed to -# `from _vendor_loop_schemas...` to match. No other lines were edited. -"""Evidence-Gated Engineering Loop schemas: models and a dependency-free validator. - -This package is the canonical, shared contract between every engineering-loop -implementation (currently the Codex and claude-python-engineering-harness -Python engineering harnesses). It defines the shape of a loop `contract`, -the mechanical `evidence` a run produces, the `verdict` derived from grading that evidence, and the -non-authoritative `builder-result` a builder reports. - -Everything here is Phase 0-1, report-only: no code in this package executes a -loop, promotes a candidate, or grants any agent the authority to certify its -own work. See README.md for the full model and the documented final states. -""" - -from _vendor_loop_schemas.models import ( - Acceptance, - Actions, - Baseline, - Budgets, - Contract, - HumanReview, - Scope, - Selection, - Trigger, -) - -__all__ = [ - "Acceptance", - "Actions", - "Baseline", - "Budgets", - "Contract", - "HumanReview", - "Scope", - "Selection", - "Trigger", -] - -__version__ = "0.1.0" +# Version: 0.1.2 +# Commit: 0459d61b7b1d4e7b46709e6d3895770553e6fab0 +# Generated by scripts/render_vendor_bundle.py; do not edit by hand. +# Phase 0-1 remains report-only: this bundle validates contracts only. +"""Vendored validators for Evidence-Gated Engineering Loop contracts.""" diff --git a/template/scripts/_vendor_loop_schemas/manifest.json b/template/scripts/_vendor_loop_schemas/manifest.json new file mode 100644 index 0000000..f81c165 --- /dev/null +++ b/template/scripts/_vendor_loop_schemas/manifest.json @@ -0,0 +1,29 @@ +{ + "adaptations": [ + { + "file": "validate_contract.py", + "from": "from loop_schemas.models import Contract", + "to": "from _vendor_loop_schemas.models import Contract" + } + ], + "files": { + "__init__.py": { + "sha256": "37a1ec4b860278188c30e64a66399a75a6f66257631db8012bffaa707aedc8b4", + "size_bytes": 333 + }, + "models.py": { + "sha256": "0683ca41aad2b185570b5d85ba6953fd6bcbfcd45930cda75983531c675f0b4e", + "size_bytes": 10785 + }, + "validate_contract.py": { + "sha256": "f089b0d2ca642c5048ed9965729c614036a65bfea764ddaeaf022bcd6fe2e672", + "size_bytes": 7323 + } + }, + "manifest_version": "1.0.0", + "source": { + "commit": "0459d61b7b1d4e7b46709e6d3895770553e6fab0", + "repository": "brunovicco/engineering-loop-schemas", + "version": "0.1.2" + } +} diff --git a/template/scripts/_vendor_loop_schemas/models.py b/template/scripts/_vendor_loop_schemas/models.py index 7184645..f5bb7dc 100644 --- a/template/scripts/_vendor_loop_schemas/models.py +++ b/template/scripts/_vendor_loop_schemas/models.py @@ -1,16 +1,8 @@ # Vendored from brunovicco/engineering-loop-schemas -# @ 75a63eef269fd995128ab39c89e551fe58a27bf7 (v0.1.0, includes the UP037/RUF100 lint fixes: a -# per-file-ignore for the load-bearing quoted return annotations, and -# removal of a stray noqa that was itself flagged once RUF is enabled). -# No published tag exists yet as of this vendoring; pinned to the commit -# above. Do not edit by hand -- re-vendor from the source repository -# instead. See docs/LOOPS.md for how this fits into the Phase 0-1 -# report-only loop foundation. -# Adapted for vendoring: this directory is named `_vendor_loop_schemas` -# (not `loop_schemas`) so it does not itself match the `scripts/loop_*` -# out-of-scope-loop-path denylist in protect_sensitive_files.py; the -# `from loop_schemas...` imports were changed to -# `from _vendor_loop_schemas...` to match. No other lines were edited. +# Version: 0.1.2 +# Commit: 0459d61b7b1d4e7b46709e6d3895770553e6fab0 +# Generated by scripts/render_vendor_bundle.py; do not edit by hand. +# Phase 0-1 remains report-only: this bundle validates contracts only. """Typed dataclasses corresponding to schemas/*.schema.json. Deliberately stdlib-only (dataclasses + typing) so this module -- and diff --git a/template/scripts/_vendor_loop_schemas/validate_contract.py b/template/scripts/_vendor_loop_schemas/validate_contract.py index c696696..7935d37 100644 --- a/template/scripts/_vendor_loop_schemas/validate_contract.py +++ b/template/scripts/_vendor_loop_schemas/validate_contract.py @@ -1,17 +1,9 @@ #!/usr/bin/env python3 # Vendored from brunovicco/engineering-loop-schemas -# @ 75a63eef269fd995128ab39c89e551fe58a27bf7 (v0.1.0, includes the UP037/RUF100 lint fixes: a -# per-file-ignore for the load-bearing quoted return annotations, and -# removal of a stray noqa that was itself flagged once RUF is enabled). -# No published tag exists yet as of this vendoring; pinned to the commit -# above. Do not edit by hand -- re-vendor from the source repository -# instead. See docs/LOOPS.md for how this fits into the Phase 0-1 -# report-only loop foundation. -# Adapted for vendoring: this directory is named `_vendor_loop_schemas` -# (not `loop_schemas`) so it does not itself match the `scripts/loop_*` -# out-of-scope-loop-path denylist in protect_sensitive_files.py; the -# `from loop_schemas...` import below was changed to -# `from _vendor_loop_schemas...` to match. No other lines were edited. +# Version: 0.1.2 +# Commit: 0459d61b7b1d4e7b46709e6d3895770553e6fab0 +# Generated by scripts/render_vendor_bundle.py; do not edit by hand. +# Phase 0-1 remains report-only: this bundle validates contracts only. """Validate an Evidence-Gated Engineering Loop contract. CLI and library. Deliberately stdlib-only so this file (and models.py, which it depends on) @@ -40,8 +32,8 @@ from _vendor_loop_schemas.models import Contract -# The hard-gate names both the Codex and claude-python-engineering-harness -# Python engineering harnesses' quality_gate.py scripts already report as named checks +# The hard-gate names shared by both Python engineering harnesses' +# quality_gate.py scripts are already reported as named checks # (see Check(...) entries in template/scripts/quality_gate.py in both # harnesses). A hard gate declared in a contract must resolve to a check # a harness's own quality gate can actually run. diff --git a/template/scripts/quality_gate.py b/template/scripts/quality_gate.py index 49af589..d073a12 100644 --- a/template/scripts/quality_gate.py +++ b/template/scripts/quality_gate.py @@ -67,6 +67,10 @@ def configured_checks(root: Path) -> list[Check]: Check("architecture", (sys.executable, "scripts/validate_architecture.py")), Check("mcp", (sys.executable, "scripts/validate_mcp_config.py")), Check("governance", (sys.executable, "scripts/governance_gate.py")), + Check( + "loop-schema-vendor", + (sys.executable, "scripts/validate_loop_schema_vendor.py"), + ), Check("loop-contracts", (sys.executable, "scripts/validate_loop_contracts.py")), ] checks.append(Check("typing", ("mypy", *type_roots) if type_roots else ())) diff --git a/template/scripts/validate_loop_schema_vendor.py b/template/scripts/validate_loop_schema_vendor.py new file mode 100644 index 0000000..e30356f --- /dev/null +++ b/template/scripts/validate_loop_schema_vendor.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Verify the offline integrity and provenance of vendored loop schemas.""" + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any, cast + +COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$") +SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +EXPECTED_REPOSITORY = "brunovicco/engineering-loop-schemas" +REQUIRED_FILES = {"__init__.py", "models.py", "validate_contract.py"} + + +def sha256_file(path: Path) -> str: + """Return the SHA-256 digest for one file.""" + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_manifest(vendor_dir: Path) -> dict[str, Any]: + """Load the local provenance manifest.""" + path = vendor_dir / "manifest.json" + document = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("manifest.json must contain an object") + return cast(dict[str, Any], document) + + +def validate_manifest(vendor_dir: Path) -> list[str]: + """Return all integrity and provenance errors.""" + errors: list[str] = [] + try: + manifest = load_manifest(vendor_dir) + except (OSError, json.JSONDecodeError, ValueError) as exc: + return [f"could not load manifest.json: {exc}"] + + if manifest.get("manifest_version") != "1.0.0": + errors.append(f"unsupported manifest_version: {manifest.get('manifest_version')!r}") + + source = manifest.get("source") + if not isinstance(source, dict): + errors.append("source must be an object") + source = {} + + repository = source.get("repository") + version = source.get("version") + commit = source.get("commit") + if repository != EXPECTED_REPOSITORY: + errors.append(f"unexpected source repository: {repository!r}") + if not isinstance(version, str) or not SEMVER_PATTERN.fullmatch(version): + errors.append(f"invalid source version: {version!r}") + if not isinstance(commit, str) or not COMMIT_PATTERN.fullmatch(commit): + errors.append(f"invalid source commit: {commit!r}") + + files = manifest.get("files") + if not isinstance(files, dict): + return [*errors, "files must be an object"] + + names = set(files) + if names != REQUIRED_FILES: + errors.append("manifest files must be exactly: " + ", ".join(sorted(REQUIRED_FILES))) + + for name in sorted(REQUIRED_FILES): + metadata = files.get(name) + if not isinstance(metadata, dict): + errors.append(f"{name}: metadata must be an object") + continue + expected_hash = metadata.get("sha256") + expected_size = metadata.get("size_bytes") + if not isinstance(expected_hash, str) or not SHA256_PATTERN.fullmatch(expected_hash): + errors.append(f"{name}: invalid sha256 in manifest") + continue + if not isinstance(expected_size, int) or expected_size < 0: + errors.append(f"{name}: invalid size_bytes in manifest") + continue + + path = vendor_dir / name + if not path.is_file(): + errors.append(f"{name}: file is missing") + continue + actual_hash = sha256_file(path) + actual_size = path.stat().st_size + if actual_hash != expected_hash: + errors.append(f"{name}: sha256 mismatch; expected {expected_hash}, got {actual_hash}") + if actual_size != expected_size: + errors.append(f"{name}: size mismatch; expected {expected_size}, got {actual_size}") + + validator = vendor_dir / "validate_contract.py" + if validator.is_file(): + text = validator.read_text(encoding="utf-8") + adapted = "from _vendor_loop_schemas.models import Contract" + original = "from loop_schemas.models import Contract" + if adapted not in text: + errors.append("validate_contract.py: vendored package import is missing") + if original in text: + errors.append("validate_contract.py: source package import was not adapted") + + for name in ("models.py", "validate_contract.py"): + path = vendor_dir / name + if not path.is_file() or not isinstance(version, str) or not isinstance(commit, str): + continue + text = path.read_text(encoding="utf-8") + if f"# Version: {version}" not in text: + errors.append(f"{name}: provenance version header is missing") + if f"# Commit: {commit}" not in text: + errors.append(f"{name}: provenance commit header is missing") + + return errors + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse CLI arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="Generated-project root; defaults to the parent of scripts/.", + ) + parser.add_argument("--json", action="store_true", dest="as_json") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Validate the generated project's local vendored bundle.""" + args = parse_args(argv) + vendor_dir = args.root.resolve() / "scripts" / "_vendor_loop_schemas" + errors = validate_manifest(vendor_dir) + + if args.as_json: + print( + json.dumps( + { + "valid": not errors, + "vendor_dir": str(vendor_dir), + "errors": errors, + }, + indent=2, + sort_keys=True, + ) + ) + elif errors: + print("Vendored loop-schema integrity check failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + else: + manifest = load_manifest(vendor_dir) + source = cast(dict[str, Any], manifest["source"]) + print(f"Vendored loop-schema bundle is valid: {source['version']} @ {source['commit']}.") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_loop_schema_vendor.py b/tests/test_loop_schema_vendor.py new file mode 100644 index 0000000..4d91f87 --- /dev/null +++ b/tests/test_loop_schema_vendor.py @@ -0,0 +1,49 @@ +"""Regression tests for the vendored loop-schema integrity gate.""" + +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VALIDATOR = ROOT / "template" / "scripts" / "validate_loop_schema_vendor.py" +VENDOR = ROOT / "template" / "scripts" / "_vendor_loop_schemas" + + +class LoopSchemaVendorIntegrityTests(unittest.TestCase): + """The generated template must ship a verified, tamper-evident bundle.""" + + def run_validator(self, root: Path) -> subprocess.CompletedProcess[str]: + """Run the validator against one synthetic generated-project root.""" + return subprocess.run( + [sys.executable, str(VALIDATOR), "--root", str(root)], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + def test_template_bundle_is_valid(self) -> None: + """The canonical template bundle matches its local manifest.""" + result = self.run_validator(ROOT / "template") + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + + def test_tampering_is_detected(self) -> None: + """Changing a vendored source file must fail the gate.""" + with tempfile.TemporaryDirectory() as directory: + project = Path(directory) + target = project / "scripts" / "_vendor_loop_schemas" + target.parent.mkdir(parents=True) + shutil.copytree(VENDOR, target) + with (target / "models.py").open("a", encoding="utf-8") as handle: + handle.write("\n# unauthorized local edit\n") + + result = self.run_validator(project) + self.assertEqual(result.returncode, 1) + self.assertIn("sha256 mismatch", result.stderr) + + +if __name__ == "__main__": + unittest.main()