diff --git a/.github/workflows/trusted-uv-materializer-quality-ci.yml b/.github/workflows/trusted-uv-materializer-quality-ci.yml index db70ec324c..cb7548708e 100644 --- a/.github/workflows/trusted-uv-materializer-quality-ci.yml +++ b/.github/workflows/trusted-uv-materializer-quality-ci.yml @@ -12,6 +12,9 @@ on: - "tests/test_uv*.py" - "tests/test_repository_branch_coverage_*.py" - "requirements-opencode-review-ci-hashes.txt" + - "requirements-strix-ci.txt" + - "requirements-strix-ci-hashes.txt" + - "requirements-strix-ci-overrides.txt" - "pyproject.toml" push: branches: [main] @@ -24,6 +27,9 @@ on: - "tests/test_uv*.py" - "tests/test_repository_branch_coverage_*.py" - "requirements-opencode-review-ci-hashes.txt" + - "requirements-strix-ci.txt" + - "requirements-strix-ci-hashes.txt" + - "requirements-strix-ci-overrides.txt" - "pyproject.toml" concurrency: @@ -134,6 +140,8 @@ jobs: tests/test_uv_redirect_boundary.py \ tests/test_uv_workspace_fail_closed.py \ tests/test_trusted_uv_materializer_quality_workflow_contract.py \ + tests/test_strix_runtime_dependencies.py \ + tests/test_trusted_uv_docstrings.py \ -q python -m coverage report @@ -161,6 +169,8 @@ jobs: tests/test_uv_redirect_boundary.py \ tests/test_uv_workspace_fail_closed.py \ tests/test_trusted_uv_materializer_quality_workflow_contract.py \ + tests/test_strix_runtime_dependencies.py \ + tests/test_trusted_uv_docstrings.py \ tests/test_repository_branch_coverage_javascript_and_noema.py \ tests/test_repository_branch_coverage_review_schedulers.py \ tests/test_repository_branch_coverage_execution_sandboxes.py \ diff --git a/CHANGELOG.d/20260911-uv-toolchain-compatibility.md b/CHANGELOG.d/20260911-uv-toolchain-compatibility.md new file mode 100644 index 0000000000..b01844d771 --- /dev/null +++ b/CHANGELOG.d/20260911-uv-toolchain-compatibility.md @@ -0,0 +1 @@ +Classify exact target `uv` version mismatches before trusted lock export so reproducibility failures remain explicit and fail closed. diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py index 3ddc126604..91a4c48929 100755 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -77,7 +77,10 @@ def _https_default_port(parsed: urllib.parse.ParseResult) -> bool: - """Return whether one parsed URL uses the implicit or explicit HTTPS port.""" + """Return whether one parsed URL uses the implicit or explicit HTTPS port. + + Invalid ports fail closed instead of being accepted as trusted defaults. + """ try: return parsed.port in (None, 443) except ValueError: @@ -88,7 +91,10 @@ def _is_trusted_uv_https_host( url: str, allowed_hosts: frozenset[str], ) -> bool: - """Return whether ``url`` is HTTPS, default-port, and host-allowlisted.""" + """Return whether ``url`` is HTTPS, default-port, and host-allowlisted. + + Userinfo is rejected so credentials cannot hide an untrusted request target. + """ parsed = urllib.parse.urlparse(url) return ( parsed.scheme == "https" @@ -100,22 +106,34 @@ def _is_trusted_uv_https_host( def _is_trusted_uv_release_request(url: str) -> bool: - """Return whether the current request is still the GitHub Releases origin.""" + """Return whether the current request is still the GitHub Releases origin. + + Only the fixed GitHub release host is valid before asset redirection. + """ return _is_trusted_uv_https_host(url, frozenset({TRUSTED_UV_RELEASE_HOST})) def _is_trusted_uv_asset_location(url: str) -> bool: - """Return whether the next hop is an official GitHub release-asset host.""" + """Return whether the next hop is an official GitHub release-asset host. + + The allowlist is limited to GitHub-owned release CDN hostnames. + """ return _is_trusted_uv_https_host(url, TRUSTED_UV_ASSET_HOSTS) def _is_trusted_uv_final_origin(url: str) -> bool: - """Return whether the completed response stayed on a trusted HTTPS origin.""" + """Return whether the completed response stayed on a trusted HTTPS origin. + + The final response may be the release host or an approved asset CDN. + """ return _is_trusted_uv_https_host(url, TRUSTED_UV_FINAL_HOSTS) class _TrustedUvReleaseAssetRedirects(urllib.request.HTTPRedirectHandler): - """Follow one GitHub Releases hop onto the official asset CDN only.""" + """Follow one GitHub Releases hop onto the official asset CDN only. + + Redirects outside the fixed release-to-CDN transition are refused. + """ def redirect_request( self, @@ -155,7 +173,10 @@ def _install_trusted_uv_url_opener() -> None: def _is_candidate_lock_name(name: str) -> bool: - """Return whether a file name is a possible pip requirements lock.""" + """Return whether a file name is a possible pip requirements lock. + + Name filtering is only a candidate pass; content validation remains authoritative. + """ return name == "requirements.lock" or ( fnmatch.fnmatch(name, "requirements*.txt") and not fnmatch.fnmatch(name, "requirements-*-ci-hashes.txt") @@ -213,7 +234,10 @@ def _bounded_requirement_include_target( def _is_bounded_requirement_include(line: str) -> bool: - """Return whether one include has a safe relative ``.txt`` target.""" + """Return whether one include has a safe relative ``.txt`` target. + + Absolute, parent-traversing, and unsupported include forms are rejected. + """ return _bounded_requirement_include_target(line) is not None @@ -274,7 +298,10 @@ def _is_flat_materializable_lock(content: bytes) -> bool: _is_fully_hash_pinned_requirement(line) for line in requirement_lines ) def _is_fully_hash_pinned_requirement(line: str) -> bool: - """Return whether one uv-export line is an exact package pin with SHA-256 hashes.""" + """Return whether one uv-export line is an exact package pin with SHA-256 hashes. + + Only registry requirements qualify; organization VCS sources use a separate path. + """ fields = re.split(r"\s+(?=--hash=)", line) if len(fields) < 2: return False @@ -298,7 +325,10 @@ def _is_fully_hash_pinned_export(content: bytes) -> bool: def _partition_uv_export(content: bytes) -> tuple[bytes, list[dict[str, str]]]: - """Separate registry hash pins from exact organization VCS source pins.""" + """Separate registry hash pins from exact organization VCS source pins. + + Any unrecognized non-comment requirement fails closed instead of being discarded. + """ registry_requirements: list[str] = [] vcs_by_repository: dict[str, dict[str, str]] = {} for line in _requirement_lines(content): @@ -340,7 +370,10 @@ def _partition_uv_export(content: bytes) -> tuple[bytes, list[dict[str, str]]]: def _git(repo_root: pathlib.Path, *args: str) -> bytes: - """Run one read-only git command in the materialized repository.""" + """Run one read-only git command in the materialized repository. + + The command is bounded and its failure is returned without exposing raw credentials. + """ completed = subprocess.run( ["git", "-C", str(repo_root), *args], check=False, @@ -354,7 +387,10 @@ def _git(repo_root: pathlib.Path, *args: str) -> bytes: def _download_trusted_uv_archive() -> bytes: - """Download the fixed uv release archive through one HTTPS trust boundary.""" + """Download the fixed uv release archive through one HTTPS trust boundary. + + Redirects and response size are constrained before archive bytes are accepted. + """ _install_trusted_uv_url_opener() try: # Keep the audited URL literal at the network sink so static analysis can @@ -386,7 +422,10 @@ def _download_trusted_uv_archive() -> bytes: def _verified_uv_binary(archive_payload: bytes) -> bytes: - """Return the bounded uv executable after archive and member verification.""" + """Return the bounded uv executable after archive and member verification. + + The archive digest, member type, and extracted size must all match policy. + """ digest = hashlib.sha256(archive_payload).hexdigest() if digest != TRUSTED_UV_ARCHIVE_SHA256: raise RuntimeError("trusted uv archive checksum verification failed") @@ -415,7 +454,10 @@ def _verified_uv_binary(archive_payload: bytes) -> bytes: @functools.cache def _install_trusted_uv() -> str: - """Install and verify the pinned uv exporter once for this process.""" + """Install and verify the pinned uv exporter once for this process. + + Only the supported runner platform and verified release binary are accepted. + """ if sys.platform != "linux" or platform.machine() != "x86_64": raise RuntimeError( "the pinned trusted uv archive supports only linux x86_64 runners" @@ -451,7 +493,10 @@ def _install_trusted_uv() -> str: def _trusted_uv_export_environment(work_dir: pathlib.Path) -> dict[str, str]: - """Create the minimal deterministic environment allowed to influence uv export.""" + """Create the minimal deterministic environment allowed to influence uv export. + + Ephemeral home, cache, and configuration paths prevent runner state from altering export. + """ directories = { "HOME": work_dir / ".uv-home", "TMPDIR": work_dir / ".uv-tmp", @@ -511,7 +556,10 @@ def _run_uv_export( def _uv_pyproject_path(lock_path: str) -> str: - """Return the sibling project metadata path for one safe tracked uv lock.""" + """Return the sibling project metadata path for one safe tracked uv lock. + + Subdirectory locks resolve only to their own project metadata file. + """ project_dir = pathlib.PurePosixPath(lock_path).parent return ( "pyproject.toml" @@ -524,7 +572,10 @@ def _reject_unsupported_uv_workspace( pyproject_content: bytes, pyproject_path: str, ) -> None: - """Reject uv workspace metadata until every immutable member is reconstructed.""" + """Reject uv workspace metadata until every immutable member is reconstructed. + + Partial workspace materialization is not sufficient evidence for export. + """ try: metadata = tomllib.loads(pyproject_content.decode("utf-8")) except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: @@ -543,6 +594,40 @@ def _reject_unsupported_uv_workspace( ) +def _reject_incompatible_uv_version( + pyproject_content: bytes, + pyproject_path: str, +) -> None: + """Fail clearly when a project requires a different exact uv version. + + The fixed verified exporter remains the only accepted toolchain in this lane. + """ + try: + metadata = tomllib.loads(pyproject_content.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + raise RuntimeError( + f"could not parse tracked base pyproject metadata {pyproject_path}" + ) from exc + + tool = metadata.get("tool") if isinstance(metadata, dict) else None + uv_metadata = tool.get("uv") if isinstance(tool, dict) else None + required_version = ( + uv_metadata.get("required-version") + if isinstance(uv_metadata, dict) + else None + ) + if not isinstance(required_version, str): + return + required_version = required_version.strip() + expected = f"=={TRUSTED_UV_VERSION}" + if required_version and required_version != expected: + raise RuntimeError( + f"tracked base {pyproject_path} requires uv {required_version}, " + f"but the verified materializer provides uv {expected}; " + "target toolchain selection is unsupported" + ) + + def _export_uv_lock( repo_root: pathlib.Path, base_sha: str, lock_path: str ) -> tuple[bytes, list[dict[str, str]]] | None: @@ -558,6 +643,7 @@ def _export_uv_lock( lock_content = _git(repo_root, "show", f"{base_sha}:{lock_path}") pyproject_content = _git(repo_root, "show", f"{base_sha}:{pyproject_path}") _reject_unsupported_uv_workspace(pyproject_content, pyproject_path) + _reject_incompatible_uv_version(pyproject_content, pyproject_path) uv_path = _install_trusted_uv() with tempfile.TemporaryDirectory() as work_dir: @@ -598,7 +684,10 @@ def _export_uv_lock( def _regular_base_blob_paths(entries: bytes) -> list[tuple[str, pathlib.PurePosixPath]]: - """Parse exact-tree output into safe regular blob paths in repository order.""" + """Parse exact-tree output into safe regular blob paths in repository order. + + Gitlinks, symlink-like modes, absolute paths, and traversal components are excluded. + """ regular_blobs: list[tuple[str, pathlib.PurePosixPath]] = [] for raw_entry in entries.split(b"\0"): if not raw_entry: @@ -628,7 +717,10 @@ def _regular_base_blob_paths(entries: bytes) -> list[tuple[str, pathlib.PurePosi def _base_python_inputs( repo_root: pathlib.Path, base_sha: str ) -> tuple[list[tuple[str, bytes]], list[dict[str, str]]]: - """Return hash locks and exact VCS sources from one validated base commit.""" + """Return hash locks and exact VCS sources from one validated base commit. + + The result is derived only from regular blobs present in the requested tree. + """ if not SHA_RE.fullmatch(base_sha): raise ValueError("base SHA must be exactly 40 hexadecimal characters") @@ -673,7 +765,10 @@ def _base_python_inputs( def base_hash_locks(repo_root: pathlib.Path, base_sha: str) -> list[tuple[str, bytes]]: - """Return regular hash-lock blobs from the exact validated base commit.""" + """Return regular hash-lock blobs from the exact validated base commit. + + VCS-only material is kept in its separate manifest and never enters pip input. + """ return _base_python_inputs(repo_root, base_sha)[0] @@ -684,7 +779,10 @@ def _included_base_lock_blobs( content: bytes, regular_paths: set[str], ) -> list[tuple[pathlib.PurePosixPath, bytes]]: - """Load direct bounded includes from the exact base as complete closures.""" + """Load direct bounded includes from the exact base as complete closures. + + Includes must be regular, relative, and fully hash-pinned before publication. + """ source_parent = pathlib.PurePosixPath(source_path).parent included: dict[pathlib.PurePosixPath, bytes] = {} for line in _requirement_lines(content): @@ -709,7 +807,10 @@ def _included_base_lock_blobs( def _rewrite_materialized_includes( content: bytes, include_directory: str, source_path: str = "" ) -> bytes: - """Rewrite root include targets to their preserved generated subtree.""" + """Rewrite root include targets to their preserved generated subtree. + + Unrelated bytes and line endings remain unchanged during path relocation. + """ try: text = content.decode("utf-8", errors="strict") except UnicodeDecodeError as exc: @@ -736,7 +837,10 @@ def materialize( base_sha: str, output_dir: pathlib.Path, ) -> list[dict[str, str]]: - """Write base locks and resolvable bounded includes into a safe context.""" + """Write base locks and resolvable bounded includes into a safe context. + + Every output manifest entry points to content selected from the exact base tree. + """ if output_dir.exists() and output_dir.is_symlink(): raise ValueError("output directory must not be a symlink") output_dir.mkdir(parents=True, exist_ok=True) @@ -784,7 +888,10 @@ def materialize( def main(argv: list[str] | None = None) -> int: - """Materialize base locks and report exactly which trusted paths were selected.""" + """Materialize base locks and report exactly which trusted paths were selected. + + Failures are emitted as bounded Actions diagnostics without exposing source content. + """ parser = argparse.ArgumentParser() parser.add_argument("--repo-root", required=True, type=pathlib.Path) parser.add_argument("--base-sha", required=True) diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index a2da04ae25..5595a98bc5 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -635,6 +635,44 @@ def test_uv_lock_is_exported_to_a_hash_pinned_lock( assert (output / "requirements-000.txt").read_bytes() == hashed +def test_uv_lock_fails_closed_on_incompatible_required_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A target uv pin must not be misreported as a stale dependency lock.""" + repo, base_sha = _uv_repo(tmp_path, with_pyproject=True) + pyproject = repo / "pyproject.toml" + pyproject.write_text( + "[project]\nname = 'demo'\nversion = '0'\n" + "[tool.uv]\nrequired-version = '==0.11.29'\n", + encoding="utf-8", + ) + git(repo, "add", "pyproject.toml") + git(repo, "commit", "-m", "pin uv") + base_sha = git(repo, "rev-parse", "HEAD") + monkeypatch.setattr( + materializer, + "_install_trusted_uv", + lambda: (_ for _ in ()).throw(AssertionError("must reject before bootstrap")), + ) + + with pytest.raises(RuntimeError, match="requires uv ==0.11.29.*unsupported"): + materializer.materialize(repo, base_sha, tmp_path / "output") + + +@pytest.mark.parametrize("metadata", [b"\xff", b"[tool.uv\n"]) +def test_uv_version_policy_rejects_malformed_metadata(metadata: bytes) -> None: + """Malformed trusted metadata cannot bypass the toolchain policy.""" + with pytest.raises(RuntimeError, match="could not parse tracked base pyproject"): + materializer._reject_incompatible_uv_version(metadata, "pyproject.toml") + + +def test_uv_version_policy_accepts_the_verified_version() -> None: + """A project matching the verified exporter version proceeds normally.""" + materializer._reject_incompatible_uv_version( + b"[tool.uv]\nrequired-version = '==0.12.1'\n", "pyproject.toml" + ) + + def test_uv_lock_fails_closed_when_trusted_uv_bootstrap_fails( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_strix_runtime_dependencies.py b/tests/test_strix_runtime_dependencies.py index fd66f8d452..cdbc368329 100644 --- a/tests/test_strix_runtime_dependencies.py +++ b/tests/test_strix_runtime_dependencies.py @@ -1,4 +1,5 @@ from pathlib import Path +import re REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -15,3 +16,28 @@ def test_strix_installs_openai_httpx2_runtime() -> None: assert "openai[httpx2]==2.54.0" in requirements.splitlines() assert "openai==2.54.0 \\" in requirements_lock.splitlines() assert "httpx2==2.12.0 \\" in requirements_lock.splitlines() + + +def test_every_exact_strix_requirement_is_present_in_the_hash_lock() -> None: + """A successful --no-deps install must not hide a missing direct package.""" + requirements = (REPOSITORY_ROOT / "requirements-strix-ci.txt").read_text( + encoding="utf-8" + ) + requirements_lock = ( + REPOSITORY_ROOT / "requirements-strix-ci-hashes.txt" + ).read_text(encoding="utf-8") + + direct_exact = re.findall( + r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([^\s;]+)", + requirements, + re.MULTILINE, + ) + missing = [ + f"{name}=={version}" + for name, version in direct_exact + if not re.search( + rf"(?m)^{re.escape(name)}=={re.escape(version)}\s+\\$", + requirements_lock, + ) + ] + assert not missing, f"direct Strix requirements missing from hash lock: {missing}" diff --git a/tests/test_trusted_uv_docstrings.py b/tests/test_trusted_uv_docstrings.py new file mode 100644 index 0000000000..ed53ce4d9e --- /dev/null +++ b/tests/test_trusted_uv_docstrings.py @@ -0,0 +1,49 @@ +"""Documentation contract for trusted uv network-boundary helpers.""" + +import ast +from pathlib import Path + + +MODULE = Path(__file__).resolve().parents[1] / "scripts/ci/materialize_base_python_requirements.py" +TARGETS = { + "_https_default_port", + "_is_trusted_uv_https_host", + "_is_trusted_uv_release_request", + "_is_trusted_uv_asset_location", + "_is_trusted_uv_final_origin", + "_TrustedUvReleaseAssetRedirects", + "_is_candidate_lock_name", + "_is_bounded_requirement_include", + "_is_fully_hash_pinned_requirement", + "_partition_uv_export", + "_git", + "_download_trusted_uv_archive", + "_verified_uv_binary", + "_install_trusted_uv", + "_trusted_uv_export_environment", + "_uv_pyproject_path", + "_reject_unsupported_uv_workspace", + "_reject_incompatible_uv_version", + "_regular_base_blob_paths", + "_base_python_inputs", + "base_hash_locks", + "_included_base_lock_blobs", + "_rewrite_materialized_includes", + "materialize", + "main", +} + + +def test_network_boundary_symbols_have_explanatory_multiline_docstrings() -> None: + """Security-sensitive URL checks explain their trust-boundary behavior.""" + tree = ast.parse(MODULE.read_text(encoding="utf-8")) + violations = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if node.name in TARGETS: + docstring = ast.get_docstring(node, clean=False) + if docstring is None or "\n" not in docstring: + violations.append((node.name, node.lineno)) + + assert not violations, f"network boundary symbols need explanatory docs: {violations}"