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..9194d23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=77"] +requires = ["setuptools==83.0.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..f4d337b --- /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==83.0.0 \ + --hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3 diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 603329d..f5b6ba6 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -37,3 +37,20 @@ 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, 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 + 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 + assert "needs: attest" in publish_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