From 93b3e2f72a0ec50c91bd9d3eb84dd861b8ae7424 Mon Sep 17 00:00:00 2001 From: Deathcharge Date: Mon, 10 Aug 2026 15:11:38 -0400 Subject: [PATCH 1/2] fix: harden request and release boundaries --- .github/workflows/release.yml | 70 ++++++++++++++++++++++++++++------ CHANGELOG.md | 4 ++ SECURITY.md | 1 + docs/RELEASING.md | 4 +- examples/from_env.py | 4 +- examples/responses_api.py | 4 +- pyproject.toml | 2 +- requirements/release-build.txt | 11 ++++++ tests/test_release_contract.py | 15 ++++++++ tests/test_router.py | 16 ++++++++ unified_llm/unified_llm.py | 49 ++++++++++++++---------- 11 files changed, 141 insertions(+), 39 deletions(-) create mode 100644 requirements/release-build.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30735b9..61a664a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,12 +10,10 @@ permissions: contents: read jobs: - build-and-attest: + quality: runs-on: ubuntu-latest permissions: contents: read - id-token: write - attestations: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -30,20 +28,34 @@ jobs: env: RELEASE_TAG: ${{ github.ref_name }} run: python -c "import os, unified_llm; expected = f'v{unified_llm.__version__}'; assert os.environ['RELEASE_TAG'] == expected, f'tag must be {expected}'" + - name: Require the tag to identify the current public main commit + if: github.ref_type == 'tag' + run: | + git fetch --no-tags origin main + test "$GITHUB_SHA" = "$(git rev-parse FETCH_HEAD)" - name: Verify the exact release source run: | python -m ruff check . python -m ruff format --check . python -m mypy unified_llm tests examples python -m pytest --cov=unified_llm --cov-report=term-missing - - name: Build and validate distributions - run: | - python -m build - python -m twine check dist/* - - name: Generate SLSA provenance - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + + build-distributions: + needs: quality + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - subject-path: dist/* + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Install the hash-locked artifact builder + run: python -m pip install --require-hashes -r requirements/release-build.txt + - name: Build distributions without isolated dependency resolution + run: python -m build --no-isolation - name: Store distributions uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -52,9 +64,45 @@ jobs: if-no-files-found: error retention-days: 30 + validate-distributions: + needs: build-distributions + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Install validation tooling + run: python -m pip install "twine>=6,<7" + - name: Download completed distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-package-distributions + path: dist/ + - name: Validate distribution metadata + run: python -m twine check dist/* + + attest: + needs: validate-distributions + runs-on: ubuntu-latest + permissions: + id-token: write + attestations: write + steps: + - name: Download completed distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-package-distributions + path: dist/ + - name: Generate SLSA provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: dist/* + publish-to-pypi: if: github.event_name == 'push' && github.ref_type == 'tag' - needs: build-and-attest + needs: attest runs-on: ubuntu-latest environment: name: pypi diff --git a/CHANGELOG.md b/CHANGELOG.md index 719e2c6..f9dac8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,12 @@ All notable changes to this project will be documented here. The project follows ### Changed - Request byte accounting now measures each exact outbound provider body, including its resolved model and generation fields. +- Request validation and one-at-a-time route serialization now run inside the configured concurrency boundary. - The built-in HTTP adapter now streams successful response bodies into a bounded buffer before JSON decoding. - Built-in provider-specific payload builders now let the router account for exact Chat Completions and Responses request bodies. +- Sanitized adapter failures no longer retain secret-bearing exception causes or contexts. +- Release artifacts use a hash-locked builder closure, a current-main tag gate, and an isolated attestation-only OIDC job. +- Runnable provider examples represent untrusted text safely before writing it to a terminal. ## 0.1.0 - 2026-07-28 diff --git a/SECURITY.md b/SECURITY.md index 66ff485..ad80c67 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,6 +16,7 @@ Samsarix LLC does not publish a response-time or remediation-time service-level - Endpoint URLs, API keys, model IDs, custom headers, and custom provider adapters are trusted operator configuration. - Message and tool content can be untrusted and crosses the configured provider boundary as JSON. +- Normalized provider text and metadata remain untrusted output; terminal and log consumers must encode control characters. - The core stores no prompts, responses, keys, usage records, or telemetry. - Remote provider retention, training, authorization, quotas, and billing remain operator responsibilities. - Retries are bounded and transient-only, but a lost response can still duplicate provider work. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1bda1ad..5faf69f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -7,7 +7,7 @@ No package has been published from this repository yet. This process makes relea 1. Confirm that the `unified-llm` PyPI project name is available or controlled by Samsarix LLC. 2. In PyPI, register a pending GitHub Trusted Publisher for owner `Deathcharge`, repository `unified-llm`, workflow `release.yml`, and environment `pypi`. 3. Create the GitHub `pypi` environment and require manual approval. Restrict deployment to protected version tags when repository policy permits it. -4. Protect `main` and release tags, require the CI checks, and review any change to `.github/workflows/release.yml` as a credential-equivalent security change. Release builds intentionally do not restore dependency caches. +4. Protect `main` and release tags, require the CI checks, and review any change to `.github/workflows/release.yml` or `requirements/release-build.txt` as a credential-equivalent security change. Release builds intentionally do not restore dependency caches. 5. Ensure at least two Samsarix-controlled recovery methods exist for the PyPI and GitHub owner accounts. Do not add a long-lived PyPI token to repository secrets. The workflow requests a short-lived OIDC credential only inside the `pypi` environment. @@ -24,7 +24,7 @@ Before tagging: ## Publication -Create and push an annotated tag exactly matching `v` plus the package version, for example `v0.1.0`. The release workflow rejects a mismatched tag, rebuilds the source distribution and wheel, checks their metadata, creates GitHub-hosted SLSA provenance, and pauses at the protected `pypi` environment. An owner must inspect the run and approve that deployment before PyPI receives anything. +Create and push an annotated tag exactly matching `v` plus the package version, for example `v0.1.0`, on the current public `main` commit. The release workflow rejects a mismatched or off-main tag. It builds the source distribution and wheel with the hash-locked builder closure in `requirements/release-build.txt`, validates them in a separate unprivileged job, creates GitHub-hosted SLSA provenance in an attestation-only OIDC job, and pauses at the protected `pypi` environment. An owner must inspect the run and approve that deployment before PyPI receives anything. The official PyPA publishing action uses Trusted Publishing and uploads PyPI attestations by default. After approval, verify the project page, both distributions, their hashes and attestations, and installation in a clean supported Python environment. Then create the GitHub release from the same tag and attach or link the verification record. diff --git a/examples/from_env.py b/examples/from_env.py index ee0544c..a7775b1 100644 --- a/examples/from_env.py +++ b/examples/from_env.py @@ -15,8 +15,8 @@ async def main() -> None: ) except UnifiedLLMError as exc: raise SystemExit(f"Request failed safely: {exc}") from exc - print(result.content) - print(f"provider={result.provider} model={result.model} total_tokens={result.total_tokens}") + print(f"content={result.content!r}") + print(f"provider={result.provider!r} model={result.model!r} total_tokens={result.total_tokens}") if __name__ == "__main__": diff --git a/examples/responses_api.py b/examples/responses_api.py index 3763d86..dda54f8 100644 --- a/examples/responses_api.py +++ b/examples/responses_api.py @@ -20,8 +20,8 @@ async def main() -> None: max_tokens=300, temperature=0.2, ) - print(response.content) - print(f"provider={response.provider} model={response.model} tokens={response.total_tokens}") + print(f"content={response.content!r}") + print(f"provider={response.provider!r} model={response.model!r} tokens={response.total_tokens}") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 8e39b73..740221e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=77"] +requires = ["setuptools==80.9.0"] build-backend = "setuptools.build_meta" [project] diff --git a/requirements/release-build.txt b/requirements/release-build.txt new file mode 100644 index 0000000..f95594e --- /dev/null +++ b/requirements/release-build.txt @@ -0,0 +1,11 @@ +# Reviewed pure-Python artifact-build closure. Update versions and hashes together. +build==1.5.0 \ + --hash=sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f +colorama==0.4.6; os_name == "nt" \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +packaging==24.2 \ + --hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 +pyproject-hooks==1.2.0 \ + --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 +setuptools==80.9.0 \ + --hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 603329d..9c32168 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -37,3 +37,18 @@ def test_release_workflow_uses_tokenless_approved_publication() -> None: assert "python -m pytest --cov=unified_llm" in source assert "password:" not in source assert "secrets." not in source + assert 'test "$GITHUB_SHA" = "$(git rev-parse FETCH_HEAD)"' in source + assert "pip install --require-hashes -r requirements/release-build.txt" in source + assert "python -m build --no-isolation" in source + + +def test_release_attestation_privileges_are_isolated_from_project_code() -> None: + source = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") + build_jobs, attest_job = source.split(" attest:\n", maxsplit=1) + + assert "id-token: write" not in build_jobs + assert "attestations: write" not in build_jobs + assert "id-token: write" in attest_job + assert "actions/attest@" in attest_job + assert "python -m pip install" not in attest_job + assert "python -m pytest" not in attest_job diff --git a/tests/test_router.py b/tests/test_router.py index b40048e..62ded9b 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import traceback import pytest @@ -222,9 +223,24 @@ async def test_unexpected_adapter_exception_is_sanitized() -> None: with pytest.raises(ProviderError) as caught: await client.generate("Hi") assert "secret-token" not in str(caught.value) + assert "secret-token" not in "".join(traceback.format_exception(caught.value)) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None assert caught.value.attempts[0].error == "adapter_error" +async def test_permanent_provider_error_does_not_retain_secret_context() -> None: + provider = ScriptedProvider("primary", [ProviderError("primary", "secret-body", retryable=False)]) + client = UnifiedLLM([Route(provider, "model-a")]) + + with pytest.raises(ProviderError) as caught: + await client.generate("Hi") + + assert "secret-body" not in "".join(traceback.format_exception(caught.value)) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + + async def test_context_manager_closes_provider() -> None: provider = ScriptedProvider("primary", [response()]) async with UnifiedLLM([Route(provider, "model-a")]) as client: diff --git a/unified_llm/unified_llm.py b/unified_llm/unified_llm.py index 202f703..803fdb1 100644 --- a/unified_llm/unified_llm.py +++ b/unified_llm/unified_llm.py @@ -943,11 +943,11 @@ async def chat_with_metadata( """Run the request through the selected bounded route set.""" selected = self._select_routes(provider=provider, model=model) - normalized_messages = self._validate_request(messages, selected, max_tokens, temperature, tools) attempts: list[Attempt] = [] total_attempts = 0 async with self._semaphore: + normalized_messages = self._validate_request(messages, selected, max_tokens, temperature, tools) if provider is None: selected = self._prioritize_healthy_routes(selected) for route, resolved_model in selected: @@ -956,6 +956,7 @@ async def chat_with_metadata( raise FallbackExhausted(attempts) total_attempts += 1 started = time.perf_counter() + terminal_error: ProviderError | None = None try: response = await asyncio.wait_for( route.provider.complete( @@ -989,7 +990,7 @@ async def chat_with_metadata( continue break except ProviderError as exc: - should_continue = await self._handle_provider_error( + outcome = await self._handle_provider_error( exc, route, resolved_model, @@ -997,10 +998,13 @@ async def chat_with_metadata( started, attempts, ) - if should_continue: + if isinstance(outcome, ProviderError): + terminal_error = outcome + elif outcome: continue - break - except Exception as exc: + else: + break + except Exception: attempt = Attempt( route.provider.name, resolved_model, @@ -1011,12 +1015,12 @@ async def chat_with_metadata( ) attempts.append(attempt) await self._notify_attempt(attempt) - raise ProviderError( + terminal_error = ProviderError( route.provider.name, f"Provider adapter {route.provider.name!r} failed unexpectedly.", retryable=False, attempts=attempts, - ) from exc + ) else: attempt = Attempt( route.provider.name, @@ -1033,6 +1037,10 @@ async def chat_with_metadata( model=response.model or resolved_model, attempts=tuple(attempts), ) + if terminal_error is not None: + # Raise outside the adapter exception handler so secret-bearing + # causes and contexts cannot cross the public SDK boundary. + raise terminal_error from None raise FallbackExhausted(attempts) @@ -1044,7 +1052,7 @@ async def _handle_provider_error( route_attempt: int, started: float, attempts: list[Attempt], - ) -> bool: + ) -> bool | ProviderError: attempt = Attempt( route.provider.name, model, @@ -1061,13 +1069,13 @@ async def _handle_provider_error( if error.status_code is not None else f"Provider {route.provider.name!r} failed." ) - raise ProviderError( + return ProviderError( route.provider.name, message, status_code=error.status_code, retryable=False, attempts=attempts, - ) from error + ) cooling_down = self._record_failure(route.provider.name) if cooling_down: return False @@ -1169,9 +1177,9 @@ def _validate_request( ) if tools is not None and not all(isinstance(tool, Mapping) for tool in tools): raise RequestValidationError("Every tool definition must be a mapping.") - try: - serialized_requests = [ - json.dumps( + for route, resolved_model in selected: + try: + serialized = json.dumps( self._request_payload( route.provider, normalized, @@ -1184,14 +1192,13 @@ def _validate_request( allow_nan=False, separators=(",", ":"), ).encode("utf-8") - for route, resolved_model in selected - ] - except (TypeError, ValueError, RecursionError) as exc: - raise RequestValidationError("Messages and tools must be JSON serializable.") from exc - if any(len(serialized) > self.max_request_bytes for serialized in serialized_requests): - raise RequestValidationError( - f"Serialized provider request exceeds the configured {self.max_request_bytes}-byte request limit." - ) + except (TypeError, ValueError, RecursionError) as exc: + raise RequestValidationError("Messages and tools must be JSON serializable.") from exc + if len(serialized) > self.max_request_bytes: + error_message = ( + f"Serialized provider request exceeds the configured {self.max_request_bytes}-byte request limit." + ) + raise RequestValidationError(error_message) return tuple(normalized) @staticmethod From efae560aa8511cb77e7c0fb7b34645ddaba07d3e Mon Sep 17 00:00:00 2001 From: Deathcharge Date: Mon, 10 Aug 2026 15:36:30 -0400 Subject: [PATCH 2/2] fix: address release hardening review --- pyproject.toml | 2 +- requirements/release-build.txt | 4 ++-- tests/test_release_contract.py | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 740221e..9194d23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==80.9.0"] +requires = ["setuptools==83.0.0"] build-backend = "setuptools.build_meta" [project] diff --git a/requirements/release-build.txt b/requirements/release-build.txt index f95594e..f4d337b 100644 --- a/requirements/release-build.txt +++ b/requirements/release-build.txt @@ -7,5 +7,5 @@ packaging==24.2 \ --hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 pyproject-hooks==1.2.0 \ --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 -setuptools==80.9.0 \ - --hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 +setuptools==83.0.0 \ + --hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3 diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 9c32168..f5b6ba6 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -44,7 +44,8 @@ def test_release_workflow_uses_tokenless_approved_publication() -> None: def test_release_attestation_privileges_are_isolated_from_project_code() -> None: source = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") - build_jobs, attest_job = source.split(" attest:\n", maxsplit=1) + build_jobs, after_attest = source.split(" attest:\n", maxsplit=1) + attest_job, publish_job = after_attest.split(" publish-to-pypi:\n", maxsplit=1) assert "id-token: write" not in build_jobs assert "attestations: write" not in build_jobs @@ -52,3 +53,4 @@ def test_release_attestation_privileges_are_isolated_from_project_code() -> None assert "actions/attest@" in attest_job assert "python -m pip install" not in attest_job assert "python -m pytest" not in attest_job + assert "needs: attest" in publish_job