Metadata-Version: 2.5
Name: cleanlib-sdk
Version: 0.5.0
Summary: CleanLibrary Python SDK — HttpVerdictClient + HttpRemediationClient + HttpEnrichClient triad (sister-shape with @cleanstart/cleanlib-sdk v0.4.4)
Project-URL: Homepage, https://cleanlibrary.clnstrt.dev
Project-URL: Documentation, https://cleanlibrary.clnstrt.dev/docs/sdk-python
Project-URL: Bug Tracker, https://cleanlibrary.clnstrt.dev/support
Author: CleanStart
License: Proprietary
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Requires-Dist: cryptography>=42
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2
Requires-Dist: rfc8785>=0.1.4
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# cleanlib-sdk

CleanLibrary Python SDK — `asyncio` + `httpx`; mirrors the Rust
`cleanlib-client` HTTP surface and the JavaScript `@cleanstart/cleanlib-sdk`
sister-shape.

**Status**: `v0.4.7` — production-ready.

## Install

```bash
pip install cleanlib-sdk
```

Requires Python 3.10+. Depends on `httpx>=0.27` and `pydantic>=2`, plus
`cryptography>=42` and `rfc8785>=0.1.4` (gate-3 attestation verification,
CLEANLIB-833/834).

## Overview — per-domain client triad (v0.4.2+)

`v0.4.2` split the SDK into three per-domain HTTP clients, each pointed at
its own CleanLibrary surface:

| Client | Endpoint (default) | Purpose |
|---|---|---|
| `HttpVerdictClient` | `https://cleanapp.clnstrt.dev` | App customer-verdict surface — `fetch_verdict` / `scan` / `audit` / `policy_preview` / `risk_accept` |
| `HttpRemediationClient` | `DEFAULT_REMEDIATION_BASE_URL` (`https://cleanapp.clnstrt.dev` — customer facade; `DIRECT_ENRICH_BASE_URL` + `facade=False` for internal) | Sparse 7-block `/v1/customer/remediation` responses |
| `HttpEnrichClient` | `DEFAULT_ENRICH_BASE_URL` (`https://cleanlib-enrich.clnstrt.dev`) | 8-verb cleanlib-enrich cascade — `get_exploitability` / `get_exploitability_bulk` / `get_epss` / `get_kev` / `get_advisories` / `get_enrich` / `get_library_verdict` / `bulk_packages_check` |

The legacy `Client` (flat-method) still ships for source-compat with
v0.4.0 callers and emits `DeprecationWarning` on `__init__`; scheduled
removal in v1.0.0. New integrations should use the per-domain triad.

## Usage — verdict client

```python
import asyncio
from cleanlib_sdk import HttpVerdictClient, PolicyDenyError, RiskAcceptanceRequiredError

async def main() -> None:
    async with HttpVerdictClient(
        base_url="https://cleanapp.clnstrt.dev",
        api_key="clk_std_...",   # opaque CleanLibrary access key
    ) as c:
        try:
            v = await c.fetch_verdict("npm", "lodash", "4.17.21")
            print(f"{v.decision} composite_score={v.composite_score}")
            print(f"reasoning: {v.reasoning}")
        except PolicyDenyError as e:
            print(f"DENIED [{e.reason_code}]: {e.message}")
        except RiskAcceptanceRequiredError as e:
            print(f"RISK ACCEPT REQUIRED: {e.message}")
            if e.docs_url:
                print(f"see: {e.docs_url}")

asyncio.run(main())
```

## Usage — enrich cascade

```python
import asyncio
from cleanlib_sdk import (
    HttpEnrichClient,
    epss_found, kev_found,
    EXPLOITABILITY_BULK_CAP,
)

async def main() -> None:
    async with HttpEnrichClient(api_key="clk_std_...") as e:
        # KEV / EPSS return a discriminated union — one of *_found / *_not_found.
        kev = await e.get_kev("CVE-2021-44228")
        if kev_found(kev):
            print(f"KEV listed: due_date={kev.due_date}")

        epss = await e.get_epss("CVE-2021-44228")
        if epss_found(epss):
            print(f"EPSS score={epss.score} percentile={epss.percentile}")

        # Bulk: server caps at EXPLOITABILITY_BULK_CAP (100) per request.
        exp = await e.get_exploitability_bulk([
            "CVE-2021-44228", "CVE-2022-22965", "CVE-2023-4863",
        ])
        for cve in exp.results:
            print(cve.cve_id, cve.availability)

asyncio.run(main())
```

## Usage — remediation

```python
import asyncio
from cleanlib_sdk import HttpRemediationClient

async def main() -> None:
    async with HttpRemediationClient(api_key="clk_std_...") as r:
        rem = await r.get_remediation("npm", "lodash", "4.17.15")
        # Sparse 7-block: any block may be absent (RemediationOrAbsent).
        if rem.recommended_version is not None:
            print(f"upgrade to {rem.recommended_version.version}")

asyncio.run(main())
```

## Usage — gate: `verdict()` / `enforce()` (CX-8)

Two ways to consume an assessment. **A blocked verdict is a successful
assessment, not an error** — only *couldn't get a verdict* is an exception, so a
transport/coverage failure can never be mistaken for "no findings, proceed"
([Absence≠safe]).

```python
from cleanlib_sdk import (
    verdict, enforce, CustomerState,
    GateBlocked, GateNotAssessed, CoverageIncompleteError,
)

# `outcome` is what your acquisition produced: a CustomerState (you got a
# verdict of some tier) or a CleanLibraryError (you could not).

# 1. verdict() — RETURNS style. A Block is a VALUE, never an exception.
a = verdict(CustomerState.MALICIOUS)          # a completed assessment
print(a.state.as_str, a.tier, a.exit_code, a.is_allowed)   # malicious Tier.BLOCK 1 False

# The "not assessed" path is still a returned value — it is NOT clean:
na = verdict(CustomerState.NOT_YET_ASSESSED)
assert not na.is_allowed and na.exit_code == 2      # warn-tier, fail-closed

# A couldn't-get-a-verdict is the ONLY thing verdict() raises on:
try:
    verdict(CoverageIncompleteError("3 of 40 coordinates unreachable", "SCAN_ABORTED"))
except CoverageIncompleteError:
    ...   # a real failure — never silently a clean result

# 2. enforce() — RAISES style for CI gates. Returns None ONLY when clean.
try:
    enforce(CustomerState.CLEAN)              # -> None, proceed
    enforce(CustomerState.NOT_YET_ASSESSED)   # -> GateBlocked (warn, exit 2)
except GateBlocked as e:
    exit(e.exit_code)                         # 1 (block) or 2 (warn)
except GateNotAssessed as e:
    exit(e.exit_code)                         # fail-closed to 1 — a timeout /
                                              # coverage failure never exits 0
```

The same contract holds byte-for-byte in sdk-js (discriminated union),
sdk-go (typed const + `Valid()`), and the Rust `cleanlib-client` reference —
all assert against the shared `CX8_GATE_EXPECTED.json` conformance fixture.

## Public API surface

Everything below is exported from the top-level `cleanlib_sdk` package
and covered by `__all__` (verified against `dir(cleanlib_sdk)` at
release-cut per CLEANLIB-83). New symbols are grouped by the version
they were introduced in.

### 0.5.0 — audit() decision + ecosystem filters (CLEANLIB-816/817)

`AuditWindow` only exposed `since`/`until` — the real server
(`cleanlib-app/src/verbs.rs`) already validates and applies `decision`
and `ecosystem` filters; verified live against prod before shipping this.

- `AuditWindow.decision: str | None` / `AuditWindow.ecosystem: str | None`
  — new optional fields.
- `validate_audit_decision(value) -> None` — case-insensitive check
  against `AUDIT_VALID_DECISIONS` (`ALLOW`/`INSUFFICIENT`/`DENY`,
  mirroring `cleanlib-cli`'s `VALID_DECISION_FILTERS`); raises
  `InvalidAuditDecisionError` for a bogus value, before the request.
- `validate_audit_ecosystem(value) -> None` — a **deliberately separate**,
  case-**insensitive** validator reusing the existing 8-ecosystem domain.
  NOT merged into `validate_ecosystem`, which stays case-sensitive — that
  is load-bearing for `fetch_verdict`/`fetch_bytes` (CLEANLIB-738: a case
  slip there silently reads as `not_yet_assessed`, not an error).
- Both `Client.audit()` (deprecated) and `HttpVerdictClient.audit()`
  validate then append `decision`/`ecosystem` as query params alongside
  `since`/`until`.

### 0.5.0 — gate-3 attestation verification (CLEANLIB-833/834)

Python replication of the Rust reference implementation
(`cleanlib-client::attestation_verify`, PR #537 on the `cleanlib` monorepo).
Q14 posture: a capability, not a default — nothing else in this SDK calls
these automatically.

- `verify_attestation(attestation_envelope, key_lookup)` — verifies a
  `SignedAttestation` wire envelope's ECDSA P-256 signature over the RFC-8785
  JCS-canonicalized `attestation` predicate. Pass the raw parsed-JSON dict
  (e.g. `response.json()["attestation"]`), not a `SignedAttestation.model_dump()`
  — see the module docstring for why. Raises `AttestationInvalidError`
  (malformed envelope / unknown key_id / genuine signature mismatch — always
  permanent) or `TransportError` (only via `PubkeysEndpointLookup`, transient).
- `PinnedKeyMap` — the DEFAULT `AttestationKeyLookup`: a compiled-in
  `key_id -> PEM` map (today's staging + prod keys), fail-closed on an
  unknown `key_id`, zero network capability. `with_extra_key(key_id, pem)`
  pins an additional out-of-band-verified key.
- `PubkeysEndpointLookup` — convenience-only helper that fetches
  `GET /v1/pubkeys`; its only sanctioned use is
  `describe_unknown_key(key_id) -> str | None`, a human-readable advisory
  that must never be treated as a trust decision. Do **not** wire this as
  `verify_attestation`'s key lookup (see module docstring — this exact shape
  was PR #536's circular-trust defect, redesigned in PR #537).
- `AttestationKeyLookup` — the lookup interface both of the above implement;
  a caller with a different trust-distribution mechanism (e.g. a
  pre-provisioned key bundle) can implement it directly.
- Reuses the existing `AttestationInvalidError` (see the CLEANLIB-657/CX-8
  entry below — same class, not redefined here); `reason_code` on the
  instances this module raises is one of the local `ATTESTATION_*` strings
  documented in the module docstring, distinct from the cross-SDK
  `ReasonCode` wire enum.

### v0.4.11 — customer-boundary facade rebase (CLEANLIB-733)

- `HttpRemediationClient` now defaults to the **cleanapp customer-boundary
  facade** (`DEFAULT_REMEDIATION_BASE_URL = https://cleanapp.clnstrt.dev`),
  calling `GET /v1/customer/remediation/{eco}/{pkg}`. Customers authenticate with
  their own `CLEANLIB_API_KEY` (opens cleanapp only); cleanapp fetches from
  cleanlib-enrich with the producer bearer server-side.
- `DIRECT_ENRICH_BASE_URL` (`https://cleanlib-enrich.clnstrt.dev`) — internal /
  producer-bearer callers bypass the facade with
  `HttpRemediationClient(facade=False, base_url=DIRECT_ENRICH_BASE_URL, bearer=<producer>)`,
  which uses the pre-facade `GET /api/v1/remediation/{eco}/{pkg}` path.
- Enrich cascade + the CVE-keyed methods remain on the direct host for now (their
  facade rebase is a follow-up; the merged facade whitelist is package-keyed
  vector/vulnerability/advisories/remediation only).

### v0.4.10 — CX-8 verdict()/enforce() dual gate API (CLEANLIB-657)

- `verdict(outcome) -> Assessment` — RETURNS style. A completed assessment of
  ANY tier (a **Block is a value**, never an exception) becomes an `Assessment`;
  a couldn't-assess propagates as its raised exception. `outcome` is a
  `CustomerState` (a verdict of some tier) or a `CleanLibraryError` (none could
  be obtained).
- `enforce(outcome) -> None` — RAISES style for gates. Returns `None` **iff**
  clean-to-proceed; a non-clean completed assessment raises `GateBlocked`, a
  couldn't-assess raises `GateNotAssessed`. Fail-closed: `NOT_YET_ASSESSED` /
  `RANGE_NOT_RESOLVED` never pass the gate, so "not assessed" can never read as
  "allowed".
- `Assessment` — a completed assessment wrapping a `CustomerState`
  (`.state` / `.tier` / `.exit_code` / `.is_allowed` / `.enforce()`).
- `GateError` — base for gate refusals; `GateBlocked` (assessed → do not
  proceed) and `GateNotAssessed` (could not assess; fail-closed exit code)
  preserve the STOP-vs-FAILURE distinction.
- `CoverageIncompleteError` / `AttestationInvalidError` — the couldn't-assess
  causes (mirror `cleanlib-client` `CleanLibraryError::CoverageIncomplete` /
  `::AttestationInvalid`).

Mirrors `cleanlib-client/src/gate.rs`; all four SDKs assert against the shared
`CX8_GATE_EXPECTED.json` conformance fixture
(`tests/test_cx8_gate_contract.py`).

### v0.4.11 — typed Ecosystem + client-side validation (CLEANLIB-738)

- `Ecosystem` — enum of the 8 supported package ecosystems (`npm` / `pypi` /
  `go` / `maven` / `crates` / `nuget` / `rubygems` / `composer`); mirrors the
  CLI's shipped accepted set and the App `SUPPORTED_ECOSYSTEMS` const. Subclasses
  `str` (`Ecosystem.NPM == "npm"`). Same typed-uncertainty class as
  `CustomerState` (CX-8).
- `validate_ecosystem(value) -> str` — returns the canonical string if supported,
  else raises `UnknownEcosystemError`. Wired into `HttpVerdictClient.fetch_verdict`
  / `fetch_bytes` so an unknown ecosystem (e.g. `cargo` for `crates`) is rejected
  **client-side, before the request**, instead of passing through to a silent
  `not_yet_assessed` ([Absence≠safe]).
- `ALL_ECOSYSTEMS` — the canonical 8-tuple (the validator's source of truth).
- `UnknownEcosystemError` — raised on an unsupported ecosystem; the message names
  the accepted set (mirrors the CLI).

### v0.4.7 — Verdict.decision auto-derived

- `Verdict.decision` is now auto-derived at the type layer (CLEANLIB-294)
  — consumers no longer need to re-run `derive_status` on a raw
  `Verdict`; the field is populated from the envelope substance +
  freshness precedence at parse time.

### v0.4.6 — envelope emit

- `verdict_to_envelope_v1(verdict) -> dict` — canonical Verdict→envelope
  packer per CLEANLIB-48. Sister-shape of the sdk-js emitter; the
  fixture-driven contract test in `tests/verdict_to_envelope_contract.py`
  keeps both SDKs byte-identical.

### v0.4.5 — previous-verdict parity

- `PreviousVerdict` — envelope carries the prior verdict when a re-scan
  changes the decision; enables audit-log diff view.

### v0.4.4 — customer-state taxonomy (CLEANLIB-178)

- `CustomerState` — enum mirroring `cleanlib-client/src/customer_state.rs`.
- `Tier` — customer tier enum (`FREE` / `STD` / `PRO` / …).
- `ALL_VERDICT_SOURCES` — canonical registry of verdict-source strings
  (used by cross-SDK contract test to guard against silent drift).

### v0.4.3 — brand/metadata sanitize

Pyproject brand cleanup; no new symbols.

### v0.4.2 — enrich cascade + F4 default-flip

**Clients:**
- `HttpVerdictClient` — App customer-verdict surface (completes the
  triad; the v0.4.1 split shipped remediation + enrich).
- `HttpEnrichClient` — 8-verb cleanlib-enrich cascade; defensive
  wrappers (advisories double-parse + dedup; `cve_id ↔ cveId`
  normalization; sparse-by-design tolerance).

**Constants:**
- `DEFAULT_ENRICH_BASE_URL` — `https://cleanlib-enrich.clnstrt.dev`.
- `DEFAULT_REMEDIATION_BASE_URL` — F4-flipped to
  `https://cleanlib-enrich.clnstrt.dev` (was the vva URL in v0.4.1).
  Consumers passing an explicit `base_url=` are unaffected.
- `EXPLOITABILITY_BULK_CAP` — server-side cap on
  `get_exploitability_bulk` batch size.
- `REMEDIATION_CACHE_TTL_SECS` — default TTL used by the remediation
  client's in-process cache.

**Enrich wire-shapes:**
- `AdvisoryRow`, `AdvisorySeverity` — advisories.
- `AvailabilityFlag`, `ExploitabilityAvailability`,
  `ExploitabilityResponse` — exploitability triage.
- `EpssResponse`, `EpssOrNotFound`, `epss_found`, `epss_not_found` —
  EPSS with found/not-found discriminated union + helpers.
- `KevResponse`, `KevOrNotFound`, `kev_found`, `kev_not_found` — KEV
  with found/not-found discriminated union + helpers.
- `EnrichResponse`, `LibraryVerdictResponse` — top-level cascade
  responses.
- `BulkPackageRef`, `BulkPackageResult`, `BulkPackagesCheckResponse` —
  `bulk_packages_check` request/response shapes.

### v0.4.1 — contract enforcement + remediation client

- `HttpRemediationClient` — sparse 7-block `/remediation` responses.
- `RemediationBlock`, `RemediationResponse`, `RemediationOrAbsent` —
  remediation wire-shapes.
- `ReasonCode` — 15-entry canonical registry (Enum).
- `ALL_REASON_CODES` — read-only tuple of every registered reason code.
- `VERDICT_ENVELOPE_V1_SCHEMA`, `SCHEMA_ID`, `STATUS_ENUM`,
  `AVAILABILITY_ENUM` — JSON-Schema mirror + enum sets.
- `derive_status(envelope) -> StatusResult` — canonical algorithm per
  App dispatch §4 binding contract (substance precedence + freshness
  override).
- `StatusResult` — output shape of `derive_status`.
- `Client` — legacy flat-method class; emits `DeprecationWarning` on
  `__init__` (v1.0.0 removal).

### v0.4.0 — verb cascade + rich-data ripple

Response types shared across the triad:

- `Verdict` — canonical verdict envelope with attestation.
- `Attestation`, `SignedAttestation` — signed provenance.
- `RecommendedVersion`, `PackageRisk`, `PackageRef` — rich-data fields.
- `ScanResult`, `ScanResponse` — `scan` endpoint.
- `AuditWindow`, `AuditWindowResponse` — `audit` endpoint.
- `PolicyPreviewResult`, `PolicyPreviewResponse` — `policy preview`.
- `RiskAcceptResponse` — `risk-accept` submission response.

### Sub-modules

Everything the flat surface re-exports also lives in a namespaced
sub-module — import from either. Use the sub-module path when you need
to disambiguate against a same-named symbol from a sister SDK.

- `cleanlib_sdk.attestation_verify` — gate-3 `verify_attestation` +
  `PinnedKeyMap` / `PubkeysEndpointLookup` / `AttestationKeyLookup` /
  `AttestationInvalidError` (CLEANLIB-833/834, PR #537 reference).
- `cleanlib_sdk.client` — legacy `Client` (deprecated).
- `cleanlib_sdk.customer_state` — v0.4.4 state taxonomy.
- `cleanlib_sdk.derive_status` — `derive_status` + `StatusResult`.
- `cleanlib_sdk.errors` — every exception in the hierarchy below.
- `cleanlib_sdk.http` — the three `Http*Client`s, wire-shapes,
  constants, helper functions.
- `cleanlib_sdk.reason_codes` — `ReasonCode`, `ALL_REASON_CODES`.
- `cleanlib_sdk.schema` — `VERDICT_ENVELOPE_V1_SCHEMA`, enum sets.
- `cleanlib_sdk.transport` — internal `httpx.AsyncClient` wrapper (not
  re-exported at the top level; used by all three `Http*Client`s).
- `cleanlib_sdk.types` — response dataclasses.
- `cleanlib_sdk.verdict_to_envelope` — `verdict_to_envelope_v1`.

## Error hierarchy

All errors descend from `CleanLibraryError`. Subclasses:

| Exception | HTTP | Triggered by |
|---|---|---|
| `PolicyDenyError` | 403 / 451 | `POLICY_DENY_VERDICT` / `POLICY_DENY_RULE_EXPLICIT` |
| `IntegrityFailureError` | 403 | `INTEGRITY_FAILURE` |
| `RateLimitExceededError` | 429 | tier-throttled; carries `retry_after_seconds` |
| `RiskAcceptanceRequiredError` | 403 | `RISK_ACCEPTANCE_REQUIRED` |
| `AuthenticationError` | 401 / 403 | `KEY_INVALID` / `KEY_EXPIRED` / `KEY_SCOPE_INSUFFICIENT` |
| `InsufficientDataError` | 403 | `INSUFFICIENT_DATA_FAIL_CLOSED` |
| `PackageNotFoundError` | 404 | not in catalog + ingest declined |
| `ServerError` | 5xx | retryable on 502/503/504 |
| `ProblemError` | any | RFC 9457 `application/problem+json` (CLEANLIB-536); carries a `ProblemDetails` — branch on `.problem.reason_class`, honor `.problem.retryable` |
| `ProblemDetails` | — | the parsed RFC 9457 problem document (`type`/`title`/`status`/`detail`/`instance` + `reason_class`/`retryable`/`self_healing`/`resolution`) |
| `TransportError` | — | network / TLS / timeout / DNS |
| `ParseError` | — | response body shape mismatch |

## Development

```bash
pip install -e ".[dev]"
pytest
ruff check .
```

Contract tests (`tests/contract.py`, `tests/customer_state_contract.py`,
`tests/verdict_to_envelope_contract.py`) load fixtures from
`cleanlib-contract-fixtures` and assert byte-identical behavior against
the sdk-js sister — do not skip these; a divergence is a wire-shape
regression.

## Cross-references

- [CleanLibrary docs](https://cleanlibrary.clnstrt.dev) — customer documentation portal
- Rust SDK: [`cleanlib-client`](https://crates.io/crates/cleanlib-client) — reference implementation
- Go SDK: [`cleanlib-sdk-go`](https://pkg.clnstrt.dev/cleanlib-sdk-go)
- JavaScript SDK: [`@cleanstart/cleanlib-sdk`](https://www.npmjs.com/package/@cleanstart/cleanlib-sdk)

## License

Proprietary — CleanStart.
