diff --git a/.github/workflows/trusted-uv-materializer-quality-ci.yml b/.github/workflows/trusted-uv-materializer-quality-ci.yml index 95642b55c..a3404232b 100644 --- a/.github/workflows/trusted-uv-materializer-quality-ci.yml +++ b/.github/workflows/trusted-uv-materializer-quality-ci.yml @@ -129,6 +129,7 @@ jobs: tests/test_trusted_uv_download_contract.py \ tests/test_trusted_uv_portability_and_streaming.py \ tests/test_uv_export_isolation_contract.py \ + tests/test_uv_flat_lock_publication_boundary.py \ tests/test_uv_redirect_and_coverage_contract.py \ tests/test_uv_redirect_boundary.py \ tests/test_uv_workspace_fail_closed.py \ @@ -155,6 +156,7 @@ jobs: tests/test_trusted_uv_download_contract.py \ tests/test_trusted_uv_portability_and_streaming.py \ tests/test_uv_export_isolation_contract.py \ + tests/test_uv_flat_lock_publication_boundary.py \ tests/test_uv_redirect_and_coverage_contract.py \ tests/test_uv_redirect_boundary.py \ tests/test_uv_workspace_fail_closed.py \ diff --git a/CHANGELOG.md b/CHANGELOG.md index da15d1dcb..872968b36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ Semantic Versioning where the repository publishes a release. - Parsed `opencode.jsonc` as JSONC (stripping `//` and `/* */` comments outside string literals) in the reasoning-effort guard and its contract tests, instead of raw `json.loads`, which rejected the file the moment it carried its first explanatory comment (added for the `contextual-orchestrator` provider block) with `Expecting property name enclosed in double quotes`. Comment markers inside string values, such as the `$schema` URL, are left untouched. - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). -- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. +- Excluded relative `-r` and `--requirement` referrers from generated flat base-lock publication while retaining bounded include syntax diagnostics and discovering independently complete direct `.txt` children of `requirements` directories. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. @@ -68,4 +68,4 @@ Semantic Versioning where the repository publishes a release. - Added DiskSage operational documentation for the hourly RCA loop, bounded retry cadence, permission model, standalone and MSA reuse, verification, rollback, and APA 7 references. - 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. \ No newline at end of file +- 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. diff --git a/docs/doctoring/trusted-uv-flat-include-isolation.md b/docs/doctoring/trusted-uv-flat-include-isolation.md new file mode 100644 index 000000000..1f5178aae --- /dev/null +++ b/docs/doctoring/trusted-uv-flat-include-isolation.md @@ -0,0 +1,79 @@ +# Trusted uv flat-include isolation + +## Status + +Accepted on 2026-08-18 for generated base Python lock publication. + +## Buyer-facing failure + +The central coverage lane renames every selected source lock to a generated flat +name such as `requirements-000.txt`. A source requirements file containing a +relative `-r` or `--requirement` directive is valid pip syntax, but pip resolves +the referenced path relative to the generated output location. Publishing only +the referrer can therefore fail a downstream repository before its own tests, +branch coverage, or docstring evidence executes. + +## Root cause and decision + +The previous implementation conflated two authority boundaries: + +- `_is_hash_pinned` answers whether a source file uses bounded requirements + syntax, including a normalized relative include; and +- `base_hash_locks` decides whether one source blob can be copied independently + under a generated flat name. + +A bounded relative include may pass the first question while failing the second. +The materializer now keeps bounded-include syntax diagnostics unchanged but uses +`_is_flat_materializable_lock` for publication. That predicate admits only a +non-empty, standalone closure whose logical requirement lines are exact `==` +pins carrying complete SHA-256 hashes. `base_hash_locks` also uses the existing +path-aware candidate predicate, so independently complete direct `.txt` children +such as `requirements/ci.txt` and `service/requirements/package.txt` remain +eligible. + +## Security and ownership boundary + +No URL, proxy, redirect, package index, caller-controlled header, output path, +review authority, credential, or repository write scope is expanded. The fixed +GitHub Releases uv download and redirect boundary is unchanged. Relative include +publication remains fail-closed until a separately reviewed implementation can +reconstruct the complete immutable include graph, preserve source-directory +identity, rewrite every edge, and prove the resulting closure. + +This is a central `.github` materialization correction. Product repositories, +including BandScope, retain ownership of their own requirements, tests, and +runtime behavior. The central workflow must not edit a downstream product merely +to work around a generated-path defect. + +## Verification and operator action + +The regression suite proves all of the following: + +1. both `-r` and `--requirement` referrers are excluded from flat publication; +2. an independently complete referenced lock remains eligible; +3. complete direct `.txt` children of a directory named `requirements` are + discovered; and +4. empty, directive-only, standalone exact-pin, and include-only inputs exercise + both branches of the publication predicate. + +Merge requires the focused trusted-uv suite, complete central tests, production +statement and branch coverage at 100%, complete production docstrings, Python +3.10 and current-stable compilation, exact-head security checks, and ordinary +protected-branch review. A downstream repository using nested requirements +should publish one standalone hash-locked closure or wait for a graph-aware +materializer; operators must not manually copy or rename an unresolved include. + +## Rollback + +Do not restore relative include publication. A rollback would reintroduce a +source-relative edge into a namespace that no longer preserves source location. +Restore only after a graph-aware implementation has equivalent RED fixtures, +immutable edge rewriting, closure verification, and the same security gates. + +## APA 7th references + +Python Packaging Authority. (2026). *Requirements file format*. pip +documentation. https://pip.pypa.io/en/stable/reference/requirements-file-format/ + +Python Packaging Authority. (2026). *Secure installs*. pip documentation. +https://pip.pypa.io/en/stable/topics/secure-installs/ diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py index e4ebf473a..4249f16be 100755 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -149,7 +149,6 @@ def _is_candidate_lock_name(name: str) -> bool: ) - def _is_candidate_lock_path(path: pathlib.PurePosixPath) -> bool: """Return whether one safe tracked path can name a pip requirements lock. @@ -242,6 +241,23 @@ def _is_hash_pinned(content: bytes) -> bool: or _is_bounded_requirement_include(line) for line in requirement_lines ) + + +def _is_flat_materializable_lock(content: bytes) -> bool: + """Return whether content is one standalone exact SHA-256 requirements lock. + + Selected sources are renamed to generated flat files. Relative ``-r`` and + ``--requirement`` edges therefore lose the source directory that gives them + meaning. Only independent exact package pins cross this publication boundary + until a complete immutable include graph can be reconstructed and rewritten. + """ + lines = _requirement_lines(content) + requirement_lines = [line for line in lines if line != "--require-hashes"] + return bool(requirement_lines) and all( + _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.""" fields = re.split(r"\s+(?=--hash=)", line) @@ -559,9 +575,9 @@ def base_hash_locks(repo_root: pathlib.Path, base_sha: str) -> list[tuple[str, b regular_paths = {path for path, _candidate in regular_blobs} locks: list[tuple[str, bytes]] = [] for path, candidate in regular_blobs: - if _is_candidate_lock_name(candidate.name): + if _is_candidate_lock_path(candidate): content = _git(repo_root, "show", f"{base_sha}:{path}") - if _is_hash_pinned(content): + if _is_flat_materializable_lock(content): locks.append((path, content)) elif candidate.name == "uv.lock": if _uv_pyproject_path(path) not in regular_paths: @@ -632,4 +648,4 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/tests/test_uv_flat_lock_publication_boundary.py b/tests/test_uv_flat_lock_publication_boundary.py new file mode 100644 index 000000000..6ef1ac2f0 --- /dev/null +++ b/tests/test_uv_flat_lock_publication_boundary.py @@ -0,0 +1,106 @@ +"""Regression tests for generated flat Python lock publication.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.ci import materialize_base_python_requirements as materializer + + +def _exact_pin(package_name: str, digest_character: str) -> bytes: + """Return one standalone exact SHA-256 requirement fixture.""" + return ( + f"{package_name}==1 --hash=sha256:{digest_character * 64}\n".encode() + ) + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + (b"", False), + (b"--require-hashes\n", False), + (_exact_pin("standalone-package", "a"), True), + (b"-r requirements-other.txt\n", False), + ], +) +def test_flat_materializable_lock_requires_a_standalone_exact_closure( + content: bytes, + expected: bool, +) -> None: + """Flat publication accepts pins but never unresolved include-only content.""" + assert materializer._is_flat_materializable_lock(content) is expected + + +@pytest.mark.parametrize("directive", ["-r", "--requirement"]) +def test_flat_publication_excludes_relative_include_referrers( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + directive: str, +) -> None: + """A generated flat name cannot preserve a source-relative include edge.""" + tree = ( + b"100644 blob " + + (b"0" * 40) + + b"\trequirements-other.txt\0" + + b"100644 blob " + + (b"1" * 40) + + b"\trequirements.txt\0" + ) + target_lock = _exact_pin("target-package", "a") + + 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 target_lock + if args[0] == "show" and args[-1].endswith(":requirements.txt"): + return f"{directive} requirements-other.txt\n".encode() + raise AssertionError(args) + + monkeypatch.setattr(materializer, "_git", fake_git) + + assert materializer.base_hash_locks(tmp_path, "a" * 40) == [ + ("requirements-other.txt", target_lock) + ] + + +def test_flat_publication_discovers_standalone_requirements_directory_locks( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Path-aware discovery keeps complete direct requirements-directory locks.""" + 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 = _exact_pin("ci-package", "a") + service_lock = _exact_pin("service-package", "b") + + 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 service_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", service_lock), + ]