diff --git a/AGENTS.md b/AGENTS.md index bd6a96a11..b6a5a404b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. -Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md). +Materialize syntax may recognize a bounded relative `-r` include (no `.`/`..`); flat publication admits only standalone exact SHA-256 pins. A lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/trusted-uv-flat-include-isolation.md`](docs/doctoring/trusted-uv-flat-include-isolation.md). Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1aebf43..6b6eda2a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,3 +75,4 @@ Semantic Versioning where the repository publishes a release. - Added fast-mlsirm operational documentation for the hourly RCA loop, psychometric scientific gates, Rust ownership, bounded retry cadence, credential isolation, modular reuse, rollback, and APA 7 references. - Documented the ordinary and conflict repair write-scope parity, ignored-path and symlink inventory, Git-control-file denial, hook suppression, explicit push destination, RED/GREEN evidence, operator response, and local-versus-protected evidence boundary. - Documented the review-authentication boundary that excludes autonomous writer control-plane paths from review-derived file authority, its test-first Strix security evidence, exact-head coverage contract, and rollback prohibition. +- Documented why pip-relative include syntax cannot cross the generated flat-lock publication boundary and the exact-head regression evidence required before the policy can change. diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py index b16d4c745..b139ba739 100755 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -52,6 +52,7 @@ } ) TRUSTED_UV_FINAL_HOSTS = frozenset({TRUSTED_UV_RELEASE_HOST, *TRUSTED_UV_ASSET_HOSTS}) +TRUSTED_UV_DOWNLOAD_USER_AGENT = "cwl-trusted-uv-materializer/1" TRUSTED_UV_ARCHIVE_SHA256 = ( "90b2f223fb69d19db49e117da601f64978593417988530aa733d456141b4bcbb" ) @@ -302,12 +303,16 @@ def _download_trusted_uv_archive() -> bytes: """Download the fixed uv release archive through one HTTPS trust boundary.""" _install_trusted_uv_url_opener() try: - # Keep the audited URL literal at the network sink so static analysis can - # prove that neither user data nor repository content selects a scheme, - # host, path, query, fragment, method, or request header. - with urllib.request.urlopen( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected # nosec B310 + # Keep the audited URL literal and static request header in this trusted + # function so neither user data nor repository content selects the + # scheme, host, path, query, fragment, method, or request header. + request = urllib.request.Request( "https://github.com/astral-sh/uv/releases/download/0.12.1/" "uv-x86_64-unknown-linux-gnu.tar.gz", + headers={"User-Agent": TRUSTED_UV_DOWNLOAD_USER_AGENT}, + ) + with urllib.request.urlopen( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected # nosec B310 + request, timeout=TRUSTED_UV_DOWNLOAD_TIMEOUT_SECONDS, ) as response: if not _is_trusted_uv_final_origin(response.geturl()): diff --git a/tests/test_trusted_uv_download_contract.py b/tests/test_trusted_uv_download_contract.py index 380151db6..9217d5a23 100644 --- a/tests/test_trusted_uv_download_contract.py +++ b/tests/test_trusted_uv_download_contract.py @@ -53,25 +53,16 @@ def _urlopen_calls() -> list[ast.Call]: ] -def test_urlopen_receives_one_literal_https_release_url() -> None: - """Static analysis can prove repository or user data never selects the URL.""" +def test_urlopen_receives_one_static_release_request() -> None: + """Static analysis can prove repository data never selects the request.""" calls = _urlopen_calls() assert len(calls) == 1 assert len(calls[0].args) == 1 - url_argument = calls[0].args[0] - assert isinstance(url_argument, ast.Constant) - assert isinstance(url_argument.value, str) - assert url_argument.value == _EXPECTED_URL - - -def test_literal_network_sink_matches_the_documented_release_constant() -> None: - """The scanner-friendly sink literal cannot drift from the release identity.""" - assert _assigned_literal("TRUSTED_UV_ARCHIVE_URL") == _EXPECTED_URL - + request_argument = calls[0].args[0] + assert isinstance(request_argument, ast.Name) + assert request_argument.id == "request" -def test_downloader_never_constructs_a_dynamic_request_object() -> None: - """The audited downloader cannot hide a dynamic URL inside ``Request``.""" request_calls = [ node for node in ast.walk(_download_function()) @@ -79,8 +70,32 @@ def test_downloader_never_constructs_a_dynamic_request_object() -> None: and isinstance(node.func, ast.Attribute) and node.func.attr == "Request" ] + assert len(request_calls) == 1 + assert len(request_calls[0].args) == 1 + url_argument = request_calls[0].args[0] + assert isinstance(url_argument, ast.Constant) + assert isinstance(url_argument.value, str) + assert url_argument.value == _EXPECTED_URL + + headers = next( + keyword.value + for keyword in request_calls[0].keywords + if keyword.arg == "headers" + ) + assert isinstance(headers, ast.Dict) + assert len(headers.keys) == 1 + assert isinstance(headers.keys[0], ast.Constant) + assert headers.keys[0].value == "User-Agent" + assert isinstance(headers.values[0], ast.Name) + assert headers.values[0].id == "TRUSTED_UV_DOWNLOAD_USER_AGENT" + assert _assigned_literal("TRUSTED_UV_DOWNLOAD_USER_AGENT") == ( + "cwl-trusted-uv-materializer/1" + ) - assert request_calls == [] + +def test_literal_network_sink_matches_the_documented_release_constant() -> None: + """The scanner-friendly sink literal cannot drift from the release identity.""" + assert _assigned_literal("TRUSTED_UV_ARCHIVE_URL") == _EXPECTED_URL def test_literal_urlopen_sink_has_one_scoped_semgrep_suppression() -> None: diff --git a/tests/test_uv_redirect_boundary.py b/tests/test_uv_redirect_boundary.py index c453070f7..4b142b7d7 100644 --- a/tests/test_uv_redirect_boundary.py +++ b/tests/test_uv_redirect_boundary.py @@ -1,9 +1,10 @@ -"""Behavioral contracts for the trusted uv download redirect boundary.""" +"""Behavioral contracts for trusted uv download and flat-lock boundaries.""" from __future__ import annotations import urllib.request from collections.abc import Iterator +from pathlib import Path import pytest @@ -181,3 +182,90 @@ def fake_install_opener(opener: object) -> None: assert isinstance(handlers[0], urllib.request.ProxyHandler) assert handlers[0].proxies == {} assert isinstance(handlers[1], materializer._TrustedUvReleaseAssetRedirects) + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + (b"", False), + (b"--require-hashes\n", False), + (b"-r requirements-other.txt\n", False), + (b"--requirement requirements-other.txt\n", False), + (b"demo==1 --hash=sha256:" + (b"a" * 64) + b"\n", True), + ], +) +def test_flat_lock_policy_requires_a_standalone_exact_hash_closure( + content: bytes, + expected: bool, +) -> None: + """Generated flat lock names cannot preserve source-relative includes.""" + assert materializer._is_flat_materializable_lock(content) is expected + + +def test_base_lock_discovery_excludes_relative_include_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A relative include never crosses from the exact base into flat output.""" + tree = ( + b"100644 blob " + + (b"0" * 40) + + b"\trequirements-other.txt\0" + + b"100644 blob " + + (b"1" * 40) + + b"\trequirements.txt\0" + ) + pinned = b"demo==1 --hash=sha256:" + (b"a" * 64) + b"\n" + + def fake_git(_repo_root: Path, *args: str) -> bytes: + if args[0] == "ls-tree": + return tree + if args[0] == "show" and args[-1].endswith(":requirements-other.txt"): + return pinned + if args[0] == "show" and args[-1].endswith(":requirements.txt"): + return b"-r requirements-other.txt\n" + raise AssertionError(args) + + monkeypatch.setattr(materializer, "_git", fake_git) + + assert materializer.base_hash_locks(tmp_path, "a" * 40) == [ + ("requirements-other.txt", pinned) + ] + + +def test_base_lock_discovery_publishes_nested_requirements_directory_locks( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Path-aware discovery still publishes standalone ``requirements/ci.txt``.""" + tree = ( + b"100644 blob " + + (b"0" * 40) + + b"\trequirements/ci.txt\0" + + b"100644 blob " + + (b"1" * 40) + + b"\tservice/requirements/package.txt\0" + + b"100644 blob " + + (b"2" * 40) + + b"\trequirements.txt\0" + ) + ci_lock = b"ci-demo==1 --hash=sha256:" + (b"a" * 64) + b"\n" + package_lock = b"service-demo==1 --hash=sha256:" + (b"b" * 64) + b"\n" + + def fake_git(_repo_root: Path, *args: str) -> bytes: + if args[0] == "ls-tree": + return tree + if args[0] == "show" and args[-1].endswith(":requirements/ci.txt"): + return ci_lock + if args[0] == "show" and args[-1].endswith(":service/requirements/package.txt"): + return package_lock + if args[0] == "show" and args[-1].endswith(":requirements.txt"): + return b"-r requirements/ci.txt\n" + raise AssertionError(args) + + monkeypatch.setattr(materializer, "_git", fake_git) + + assert materializer.base_hash_locks(tmp_path, "a" * 40) == [ + ("requirements/ci.txt", ci_lock), + ("service/requirements/package.txt", package_lock), + ]