diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0de7d49 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,162 @@ +# ============================================================================= +# Release & publish workflow (conclave v1.0 — distribution + release-eng) +# +# !!! INERT UNTIL CONFIGURED — SAFE TO MERGE !!! +# +# This workflow does NOTHING until BOTH of the following are true: +# (a) a GitHub *Release* is published (a pushed git tag ALONE does not trigger +# it — the trigger is `release: published`, the explicit human gesture), AND +# (b) the `conclave-cli` PyPI project + its OIDC Trusted Publisher are configured +# by the owner (see RELEASING.md "One-time PyPI setup"). +# +# Until (a) AND (b) hold, merging this file changes nothing at runtime: no tag is +# cut here, no version is bumped here, nothing is published here. Cutting v1.0.0 is +# then a single tag + GitHub Release away (the full runbook is in RELEASING.md). +# +# What it does WHEN a Release is published: +# build — builds sdist + wheel with `python -m build`, uploads them as +# workflow artifacts so publish + sign consume the exact same bytes. +# pypi-publish — publishes those artifacts to PyPI via OIDC Trusted Publishing +# (NO API token, NO stored secret), with PEP 740 attestations. +# Fails CLOSED if the Trusted Publisher is not yet configured — +# it never falls back to anything insecure. +# sign — signs the sdist + wheel with Sigstore keyless (ambient OIDC -> +# Fulcio) and attaches the `.sigstore` bundles to the GitHub +# Release assets. +# +# Distribution-name note: the PyPI distribution name is `conclave-cli` (the name +# `conclave` is taken by an unrelated project). The command + import package stay +# `conclave`. The Trusted Publisher must therefore name PROJECT `conclave-cli`. +# +# Pin discipline: every `uses:` is pinned to a full 40-char commit SHA with a +# `# vX.Y.Z` comment. conclave's other workflows (test.yml, gitleaks.yml) pin to +# mutable major tags; this publish workflow holds elevated OIDC permissions, so it +# is hardened one level further with full commit-SHA pins. +# ============================================================================= +name: Release + +on: + # Primary, safe trigger: only fires once a GitHub Release is *published*. + # A pushed tag by itself does NOT publish a Release and therefore does NOT + # trigger this workflow. + release: + types: [published] + +# Least-privilege at the top level; each job widens scope locally only as needed. +permissions: + contents: read + +jobs: + # -------------------------------------------------------------------------- + # Job 1: build sdist + wheel and publish them as workflow artifacts so the + # publish and sign jobs consume the exact same bytes (no rebuild drift). + # -------------------------------------------------------------------------- + build: + name: Build sdist + wheel + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Python 3.12 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + cache: pip + + - name: Install build frontend + run: python -m pip install --upgrade "build==1.2.2.post1" + + - name: Build sdist + wheel + run: python -m build --sdist --wheel --outdir dist/ + + - name: Show built artifacts + run: ls -l dist/ + + - name: Upload dist artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dist + path: dist/ + if-no-files-found: error + + # -------------------------------------------------------------------------- + # Job 2: publish to PyPI via OIDC Trusted Publishing. + # + # No API token, no stored secret: pypa/gh-action-pypi-publish mints a short-lived + # OIDC token from this job's `id-token: write` and exchanges it for a PyPI upload + # token. PyPI must have a Trusted Publisher (pending or active) for `conclave-cli` + # pointing at owner=ernestprovo23, repo=conclave, workflow=release.yml — see + # RELEASING.md. Until that exists, this job fails CLOSED (publish denied); it + # never falls back to anything insecure. + # + # PEP 740 attestations are generated + attached by default (the action's + # `attestations: true` default) since OIDC Trusted Publishing is in use. + # -------------------------------------------------------------------------- + pypi-publish: + name: Publish to PyPI (OIDC Trusted Publishing) + needs: build + runs-on: ubuntu-latest + # id-token: write is scoped to THIS job only (never granted at workflow top level). + permissions: + id-token: write # REQUIRED for OIDC Trusted Publishing — mints the token + contents: read + + steps: + - name: Download dist artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: dist + path: dist/ + + # Production PyPI via Trusted Publishing (OIDC). No `password:` — the action + # uses ambient OIDC. PEP 740 attestations are on by default. + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + packages-dir: dist/ + # No `password:` — OIDC Trusted Publishing is used (no stored secret). + # `attestations` defaults to true under Trusted Publishing -> PEP 740. + + # -------------------------------------------------------------------------- + # Job 3: sign the release artifacts with Sigstore keyless. + # + # The job's ambient OIDC mints a short-lived Fulcio cert (issuer + # https://token.actions.githubusercontent.com), signs each dist file, and + # attaches the resulting `.sigstore` bundle(s) to the GitHub Release assets + # (release-signing-artifacts, the action's default on a release event). + # + # We self-verify (`verify: true`) against THIS workflow's own identity so a + # broken run can never publish a bad bundle. + # -------------------------------------------------------------------------- + sign: + name: Sign release artifacts (Sigstore keyless) + needs: build + runs-on: ubuntu-latest + permissions: + id-token: write # REQUIRED for ambient OIDC -> Fulcio + contents: write # REQUIRED to attach .sigstore bundles to the Release assets + + steps: + - name: Download dist artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: dist + path: dist/ + + - name: Sign sdist + wheel and attach bundles to the Release + uses: sigstore/gh-action-sigstore-python@5b79a39c381910c090341a2c9b0bf022c8b387e1 # v3.4.0 + with: + inputs: ./dist/*.tar.gz ./dist/*.whl + # Self-check: verify what we just signed against THIS workflow's own + # identity so a broken run never publishes a bad bundle. + verify: true + verify-cert-identity: "https://github.com/ernestprovo23/conclave/.github/workflows/release.yml@${{ github.ref }}" + verify-oidc-issuer: "https://token.actions.githubusercontent.com" + # release-signing-artifacts defaults to true: on a release event the + # .sigstore bundles are attached to the Release assets automatically. + release-signing-artifacts: true + upload-signing-artifacts: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd79caf..e915da5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,3 +62,39 @@ jobs: - name: ruff format --check run: ruff format --check . + + audit: + name: "pip-audit" + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-audit-${{ hashFiles('pyproject.toml') }} + + # Install the project so its resolved runtime + dev deps are present in the + # environment, then audit that environment. conclave is security-positioned, + # so this gate is FAIL-CLOSED: a known vulnerability in any resolved + # dependency fails CI. The dep surface is tiny (httpx + a few well-maintained + # libs), so false-positive churn is low. If a transitive CVE with no fix + # blocks an unrelated PR, suppress it narrowly with + # `--ignore-vuln ` and a tracking note (see RELEASING.md). + - name: Install package with dev extras + run: pip install -e ".[dev]" + + - name: Install pip-audit + run: pip install pip-audit + + # `--skip-editable` drops the local editable `conclave` install (it is not on + # PyPI and cannot be audited); every real dependency is still audited. + - name: Audit resolved dependencies for known vulnerabilities + run: pip-audit --skip-editable diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..369c978 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,30 @@ +# gitleaks configuration for conclave. +# +# conclave is a bring-your-own-keys tool whose security tests deliberately plant +# OBVIOUSLY-FAKE, key-SHAPED strings (e.g. "sk-FAKE...", "AIza-FAKE...") to prove +# that redact() / the cache / streaming never let a real key escape. Those fake +# fixtures are not secrets, but a key-shaped literal can trip gitleaks' generic +# rules. This allowlist scopes that exception to the test tree ONLY, so a real +# secret committed anywhere in the source or docs is still caught. +# +# We extend gitleaks' bundled default ruleset rather than replacing it, so every +# upstream detector stays active outside the allowlisted paths. + +[extend] +useDefault = true + +[allowlist] +description = "Fake, key-shaped fixtures used by the key-leak regression tests are not secrets." +# Restrict the allowance to the tests directory: production code, docs, and +# config are still fully scanned. +paths = [ + '''tests/.*\.py''', +] +# Defense in depth: also allow the explicit fake-key marker tokens anywhere they +# appear, so a fixture moved/quoted in a doc example is not flagged. These are +# intentionally synthetic sentinels, never real credentials. +regexes = [ + '''FAKE-?[A-Za-z0-9_\-]*''', + '''sk-(test|FAKE|streamleak|CONCLAVE)[A-Za-z0-9_\-]*''', + '''AIza-?(dummy|test|FAKE)[A-Za-z0-9_\-]*''', +] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..20e3d7e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,81 @@ +# Changelog + +All notable changes to conclave are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-06-14 + +First stable release. conclave is feature-complete for its 1.0 scope: a +bring-your-own-keys multi-model council that fans a prompt to N foundation +models concurrently and merges their answers. This release integrates three +release-readiness workstreams — distribution/release engineering, key-leak +hardening + threat model, and synthesizer behavior documentation/versioning — +on top of v0.3.0. + +### Added + +- **Distribution name.** The package is now published to PyPI as `conclave-cli` + (`pip install conclave-cli`); the import package, CLI command, and repo all + stay `conclave`. The bare PyPI name `conclave` is an unrelated project. +- **Release engineering.** OIDC Trusted-Publisher release workflow + (`.github/workflows/release.yml`) with Sigstore keyless signing and PEP 740 + attestations, inert until a GitHub Release fires and the publisher is + configured; a hash-pinned dev + runtime lockfile (`requirements-dev.lock`) + for reproducible installs/CI; and a `RELEASING.md` operator runbook. +- **Supply-chain CI.** A fail-closed `pip-audit` job added to the CI workflow. +- **Threat model.** `SECURITY.md` now carries a BYO-keys threat model and the + key-handling guarantees consumers can rely on; `.gitleaks.toml` plus a + dedicated `tests/test_keyleak_audit.py` regression suite guard against + secret leakage. +- **Versioned synthesis prompt.** The synthesis prompt set is versioned via + `conclave.prompts.SYNTHESIS_PROMPT_VERSION` and stamped onto every + `CouncilResult.prompt_version`, so a downstream eval can detect a prompt + change rather than silently absorb it. + +### Changed + +- **Key-leak: cause-chain fix.** The originating `httpx` exception is no longer + attached to `TransportError.__cause__`, closing a path where a verbose + traceback could surface a key-bearing transport exception. +- **Key-leak: transport-logging guard default-on.** `Council.__init__` now + installs `conclave.transport.guard_transport_logging()` by default, dropping + the httpx/httpcore `DEBUG` records that emit the auth header. Callers who + genuinely need that DEBUG band opt out with + `Council(..., allow_transport_debug_logging=True)`. +- **Synthesizer: observable degradation.** Synthesizer/judge degradation is + confirmed (never silent) across synthesize, debate, and the adversarial-judge + paths: an unkeyed or failed synthesizer surfaces on + `CouncilResult.synthesis_error` (and `AdversarialResult.verdict_error`, + mirrored to `synthesis_error`), with no path where synthesis is both absent + and unexplained. +- **Synthesizer behavior documented.** README gains a "Synthesizer behavior" + section covering selection precedence (`synthesizer=` arg → config → + default), observable degradation, and the versioned prompt. + +### Scope + +- Feature-complete for 1.0: 4 council modes (synthesize / raw / debate / + adversarial), 9 providers, streaming for synthesize/raw, an optional result + cache, and debate convergence early-stop. + +### Roadmap (post-1.0) + +- `vote` mode (council issue #3) — a ranked/tallied decision mode — is + documented as planned, not shipped. +- A stdio MCP server (council issue #8) is documented as planned; the earlier + HTTP local-server-mode spike was evaluated and shelved. + +## [0.3.0] - 2026-06-08 + +- Provider-highway refactor: LiteLLM removed in favor of an owned `httpx` + transport + adapter registry across the (then) 5 providers. +- CI foundation: GitHub Actions matrix, ruff lint/format, coverage floor, + gitleaks, and branch protection. +- Key-leak fix in `redact()` for custom OpenAI-compatible endpoints; CLI + exit-code contract and httpx client lifecycle hardening; transport/CLI/logging + test backfill; first public release with community files. + +[1.0.0]: https://github.com/ernestprovo23/conclave/compare/v0.3.0...v1.0.0 +[0.3.0]: https://github.com/ernestprovo23/conclave/releases/tag/v0.3.0 diff --git a/DOCUMENTATION_INDEX.md b/DOCUMENTATION_INDEX.md index c1359d9..facf7f6 100644 --- a/DOCUMENTATION_INDEX.md +++ b/DOCUMENTATION_INDEX.md @@ -6,8 +6,8 @@ context diagram, and this index linking everything together. The Product Design the canonical authority spec on top of those. - **Repo:** `/Users/ernestprovo/dev/conclave/` -- **Version:** 0.3.0 · **License:** MIT -- **Last updated:** 2026-06-09 +- **Version:** 1.0.0 · **License:** MIT +- **Last updated:** 2026-06-14 --- @@ -48,7 +48,7 @@ Package root: `src/conclave/` (installed as the `conclave` package; console scri | Gemini adapter | [`src/conclave/adapters/gemini.py`](src/conclave/adapters/gemini.py) | `GeminiAdapter` — native `generateContent`, OpenAI-role mapping, `usageMetadata`. | | Registry | [`src/conclave/registry.py`](src/conclave/registry.py) | Friendly-name → model-id defaults; provider → env-var mapping; key **presence** logic (never values). | | Config | [`src/conclave/config.py`](src/conclave/config.py) | Loads/merges `~/.conclave/config.yml` over defaults; resolves model ids and named/CSV councils; parses the `endpoints:` section (custom OpenAI-compatible providers). | -| Models | [`src/conclave/models.py`](src/conclave/models.py) | Pydantic result contract: `TokenUsage`, `ModelAnswer`, `StreamEvent`, `DebateRound`, `AdversarialResult`, `CouncilResult` (`mode`/`rounds`/`adversarial`). Stable downstream surface. | +| Models | [`src/conclave/models.py`](src/conclave/models.py) | Pydantic result contract: `TokenUsage`, `ModelAnswer`, `StreamEvent`, `DebateRound`, `AdversarialResult`, `CouncilResult` (`mode`/`rounds`/`adversarial`/`synthesis_error`/`prompt_version`). Stable downstream surface. | | CLI | [`src/conclave/cli.py`](src/conclave/cli.py) | `conclave ask` (synthesize/raw/debate/adversarial; `--rounds`/`--proposer`/`--stream`) + `conclave providers`; rich panels, live `--stream` output, and `--json`; never prints key values. | | Logging | [`src/conclave/logging.py`](src/conclave/logging.py) | Logger factory; stderr; verbosity via `CONCLAVE_LOG_LEVEL` (default `WARNING`). | @@ -57,6 +57,7 @@ Package root: `src/conclave/` (installed as the `conclave` package; console scri | File | Path | Covers | |------|------|--------| | Council tests | [`tests/test_council.py`](tests/test_council.py) | Fan-out, partial failure, synthesis behavior. | +| Synthesizer tests | [`tests/test_synthesizer.py`](tests/test_synthesizer.py) | Pins the synthesizer/judge contract: default + configurable (arg/config/CLI `--synthesizer`) selection; observable degradation (unkeyed/failed → `synthesis_error`/`verdict_error`, never silent) for synthesize, debate, and the adversarial judge; versioned synthesis prompt (`SYNTHESIS_PROMPT_VERSION` + `result.prompt_version`) with prompt-text + version pins. | | Modes tests | [`tests/test_modes.py`](tests/test_modes.py) | Debate multi-round flow, mid-round drop-out, peer anonymization; adversarial proposer/critic/verdict, proposal/critic failure paths, no-key judge, sync wrappers. | | Adapter tests | [`tests/test_adapters.py`](tests/test_adapters.py) | Per-adapter `build_request` + `parse_response` for openai-compat/anthropic/gemini: system-hoist, max_tokens, role mapping, usage parsing, empty/malformed/error-status raises. | | Provider highway tests | [`tests/test_providers.py`](tests/test_providers.py) | `resolve_adapter` (built-in prefixes, per-provider URLs, custom endpoints, unknown-prefix raise), end-to-end `call_model`, and `redact()` (bearer/`sk-`/env-var-value/`x-api-key` scrubbing; pre-redacted provider errors). | @@ -65,6 +66,7 @@ Package root: `src/conclave/` (installed as the `conclave` package; console scri | Transport tests | [`tests/test_transport.py`](tests/test_transport.py) | `post_json` via httpx `MockTransport`: success/error-status/non-JSON fallback, timeout & connect/HTTP errors → `TransportError` (key never leaks), client reuse/pooling, aclose idempotency. | | Streaming tests | [`tests/test_streaming.py`](tests/test_streaming.py) | Per-adapter SSE via `MockTransport` (openai-compat/anthropic/gemini): incremental chunks + assembled answer == concatenation == buffered result; mid-stream malformed-frame/connection-drop/non-2xx → error set with partial text preserved (never raises); key redaction in stream errors; buffered `ask()` never opens a stream; `Council.ask_stream` interleaving + terminal `done` shape; CLI `--stream` smoke + exit-code contract + debate rejection; `--stream` + cache one-shot replay. | | Logging tests | [`tests/test_logging.py`](tests/test_logging.py) | `CONCLAVE_LOG_LEVEL` resolution (default `WARNING`, case-insensitive, unknown → `WARNING`), factory contract, one-shot configuration. | +| Key-leak audit tests | [`tests/test_keyleak_audit.py`](tests/test_keyleak_audit.py) | Threat-model regression guards: no API key (bearer/`sk-`/env-var value/`x-api-key`) leaks via exception messages, `__cause__` chains, `repr`, or transport debug logging; transport-logging guard is default-on and opt-out only via `allow_transport_debug_logging`. | | Fixtures | [`tests/conftest.py`](tests/conftest.py) | Shared fixtures; mocks the httpx transport so the suite needs no network and no API keys. | Run: `pytest` (config in `pyproject.toml`, `asyncio_mode = "auto"`). @@ -73,7 +75,10 @@ Run: `pytest` (config in `pyproject.toml`, `asyncio_mode = "auto"`). | File | Path | Purpose | |------|------|---------| -| Packaging | [`pyproject.toml`](pyproject.toml) | hatchling build, deps (httpx, pydantic, rich, typer, pyyaml — no LLM SDK), dev extras, console script, pytest config. License: MIT. | +| Packaging | [`pyproject.toml`](pyproject.toml) | hatchling build, deps (httpx, pydantic, rich, typer, pyyaml — no LLM SDK), dev extras, console script, pytest config. License: MIT. **PyPI distribution name `conclave-cli`** (the name `conclave` is an unrelated project); command + import stay `conclave`. | +| Release runbook | [`RELEASING.md`](RELEASING.md) | Operator runbook: one-time PyPI OIDC Trusted-Publisher setup for `conclave-cli`, cut-a-release checklist (bump→tag→publish Release), post-release verification (Sigstore bundle, PEP 740 attestations), rollback/yank. | +| Changelog | [`CHANGELOG.md`](CHANGELOG.md) | Keep-a-Changelog history per release (SemVer). The 1.0.0 entry covers the distribution rename, key-leak hardening, synthesizer versioning, and release engineering; post-1.0 roadmap notes (vote mode, stdio MCP server). | +| Dev lockfile | [`requirements-dev.lock`](requirements-dev.lock) | Hash-pinned dev + runtime tree for reproducible installs/CI. Regenerate via `uv pip compile --universal --generate-hashes --python-version 3.11 --extra dev pyproject.toml -o requirements-dev.lock`. | | License | [`LICENSE`](LICENSE) | MIT License. Copyright (c) 2026 Ernest Provo. Matches the `pyproject.toml` license field. | | Security policy | [`SECURITY.md`](SECURITY.md) | BYO-keys vulnerability reporting policy: how to report, scope, and the key-handling guarantees consumers can rely on. | | Contributing guide | [`CONTRIBUTING.md`](CONTRIBUTING.md) | Dev setup, the BYO-keys contract for contributors, and the PR checklist (tests, ruff lint/format, coverage). | @@ -91,6 +96,10 @@ Run: `pytest` (config in `pyproject.toml`, `asyncio_mode = "auto"`). | Date | Change | |------|--------| +| 2026-06-14 | v1.0.0 release. Version bump 0.3.0 → 1.0.0; new `CHANGELOG.md` (Keep-a-Changelog). Integrates the three v1.0 PRs below. | +| 2026-06-14 | v1.0 distribution + release-engineering (PR-A): PyPI distribution name → `conclave-cli` (command + import stay `conclave`); OIDC Trusted-Publishing release workflow (`.github/workflows/release.yml`, SHA-pinned, PEP 740 attestations, Sigstore keyless signing, inert until a Release fires + publisher configured); `pip-audit` fail-closed CI job in `test.yml`; hash-pinned `requirements-dev.lock`; `RELEASING.md` operator runbook. | +| 2026-06-14 | Key-leak audit + threat model (v1.0 readiness): cause-chain leak fix (httpx exception dropped from `TransportError.__cause__`); transport-logging guard default-on in `Council.__init__` (opt-out via `allow_transport_debug_logging`); SECURITY.md threat model; `.gitleaks.toml` + `tests/test_keyleak_audit.py`. | +| 2026-06-14 | Documented + tested synthesizer behavior (v1.0 readiness must-do #5): README "Synthesizer behavior" section (selection precedence, observable degradation, versioned prompt); synthesis prompt set now versioned via `conclave.prompts.SYNTHESIS_PROMPT_VERSION`, stamped onto every `CouncilResult.prompt_version`; confirmed (not silent) degradation across synthesize/debate/adversarial-judge paths; new `tests/test_synthesizer.py` (21 tests). No non-synthesis behavior changed. | | 2026-06-09 | Roadmap features shipped: adversarial proposer resilience (#9), optional result cache (#6), debate convergence early-stop (#4), 4 first-class providers groq/deepseek/mistral/together (#5), streaming for synthesize/raw (#7); tests 121→191. #8 local-server-mode spike evaluated (no-go on HTTP). Doc sync: System Context diagram now shows all 9 providers; PDD §12 resolved questions archived to `docs/archive/pdd-resolved-questions-2026-06-09.md` (PDD back under 500 lines); `config.example.yml` stale "LiteLLM" comment fixed. | | 2026-06-08 | v0.3.0 version bump; CI foundation (Actions matrix, ruff, coverage floor, gitleaks, branch protection); redact() custom-endpoint key-leak fix (#14); status_error consolidation + conditional temperature (#16/#22); provider-metadata single-source + import-time drift guard + config memoization (#19/#15); CLI exit-code contract + httpx client lifecycle (#17/#20); transport/cli/logging test backfill (#18); public release + community files. | | 2026-06-08 | PDD §11 repositioned vs. new direct peers (`llm-council-core`, `the-llm-council`); §12 Q1/Q3/Q4/Q5 resolved. Index Tests table updated for the PR #2 split (`test_adapters.py`, `test_providers.py`). | diff --git a/README.md b/README.md index bb2e5a4..3fe1153 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,19 @@ See the canonical spec and design docs: ## Install +```bash +pip install conclave-cli +``` + +> **Name split (read this once).** The PyPI **distribution** name is +> `conclave-cli` — the name `conclave` on PyPI is an *unrelated* project (a +> blockchain client, not this one). Everything else stays `conclave`: the CLI +> command you type is `conclave`, the package you import is `conclave`, and the +> repo is `conclave`. So: +> `pip install conclave-cli` → run `conclave ...` / `from conclave import Council`. + +From a source checkout (for development), install it editable instead: + ```bash # from the repo root pip install -e . @@ -199,6 +212,45 @@ print("VERDICT:\n", adv.adversarial.verdict) # also mirrored to adv.synthesis critiques populate `answers` and the verdict mirrors into `synthesis` — so code written against the v0.1 surface keeps working across every mode. +## Synthesizer behavior + +The synthesizer is the single model that merges the council's answers (and is the +**judge** in `adversarial` mode and the final consolidator in `debate`). It is +chosen by this precedence, highest first: + +1. the `synthesizer=` argument to `Council` (CLI: `--synthesizer/-s`); +2. the `synthesizer:` key in `~/.conclave/config.yml`; +3. the built-in default — **`claude`** (`anthropic/claude-sonnet-4-6`). + +```bash +conclave ask "..." --council grok,gemini --synthesizer openai # override per run +``` + +**Degradation is observable, never silent.** Synthesis is skipped — and the +reason is always surfaced on the result — in three cases: + +| Situation | What happens | +|---|---| +| No usable member answers (all errored/skipped) | `synthesis = None`, `synthesis_error = "no successful member answers…"` | +| Synthesizer has no API key | `synthesis = None`, `synthesis_error = "…has no API key; returning raw answers only"`; member answers preserved | +| Synthesizer call fails | `synthesis = None`, `synthesis_error =` the provider error | + +In every case the member answers are returned intact and a warning is logged, so +a caller can reliably detect a non-synthesis with +`result.synthesis is None and result.synthesis_error is not None`. There is **no +path** where concatenated or partial output is silently returned as if it were a +synthesis. In `adversarial` mode the same signal lands on +`adversarial.verdict_error` (mirrored to `synthesis_error`). + +**The synthesis prompt is a versioned constant.** The synthesize-mode system +prompt is fixed in code (not built per call); the debate/judge prompts live in +`conclave.prompts`. The whole prompt set carries a version tag, +`conclave.prompts.SYNTHESIS_PROMPT_VERSION`, stamped onto **every** +`CouncilResult` as `result.prompt_version`. A downstream eval or regression suite +can compare it across runs to detect that the synthesis wording changed, instead +of silently attributing the shift to model drift. The test suite pins both the +prompt text and the version, so changing one without the other fails CI. + ## Config (optional) Create `~/.conclave/config.yml` to add models, define named councils, and set a diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..211b618 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,199 @@ +# Releasing conclave + +This is the operator runbook for cutting a release of **conclave**. + +Three names matter and they are deliberately different: + +| Thing | Value | +|-------|-------| +| PyPI distribution name (what `pip install` uses) | `conclave-cli` | +| CLI command (what users type) | `conclave` | +| Import package (what you `import`) | `conclave` | +| GitHub repository | `ernestprovo23/conclave` | + +Install is therefore `pip install conclave-cli`, but the command stays `conclave` +and the import stays `from conclave import Council`. The PyPI name `conclave` is an +**unrelated** package by another author (a blockchain client) — that is why the +distribution name is `conclave-cli`. + +The publish + signing automation lives in +[`.github/workflows/release.yml`](.github/workflows/release.yml). That workflow is +**inert until configured**: it only fires when a GitHub *Release* is published, and +the publish job only succeeds once the one-time PyPI Trusted Publisher below exists. + +--- + +## 0. One-time PyPI setup (do this ONCE, before the first release) + +The workflow publishes via **OIDC Trusted Publishing** — there is no API token and +no secret stored in GitHub. Instead, PyPI is told to trust releases that come from +this exact repo + workflow. Configure the publisher *before* the first release so +the very first upload is already OIDC-published. + +### Recommended path — "pending publisher" (zero prior upload required) + +1. Log in to as the account that will own `conclave-cli`. +2. Go to **Account → Publishing** (). +3. Under **Add a new pending publisher**, fill in **exactly**: + - **PyPI Project Name**: `conclave-cli` + - **Owner**: `ernestprovo23` + - **Repository name**: `conclave` + - **Workflow name**: `release.yml` + - **Environment name**: *(leave blank — the workflow does not use a GitHub + deployment environment; if you later add one, set it here and add an + `environment:` block to the `pypi-publish` job)* +4. Save. PyPI now reserves the project name `conclave-cli` and will create it on the + first successful OIDC upload from `release.yml`. + +A "pending publisher" reserves the name and lets the FIRST release be OIDC-published +— no manual upload, no token ever. This is the clean path for conclave: there is no +prior token-publish history, so the supply chain is OIDC-only from release #1. + +### Alternative path — manual first upload, then configure + +If you would rather seed the project manually first: + +1. Build locally: `python -m build` (produces `dist/*.tar.gz` + `dist/*.whl`). +2. `twine upload dist/*` with a temporary PyPI API token (creates `conclave-cli`). +3. Then go to **Manage project → Publishing** on the new `conclave-cli` project and + add the Trusted Publisher with the same owner/repo/workflow values as above. +4. Revoke the temporary token. + +> Prefer the pending-publisher path. It avoids ever minting a long-lived token and +> keeps the entire supply chain OIDC-only from release #1. + +--- + +## 1. Cut a release + +Do this on a clean checkout of `main` with all v1 PRs merged. + +1. **Update the changelog.** In [`CHANGELOG.md`](CHANGELOG.md), move the + `## [Unreleased]` entries under a new `## [1.0.0] - ` heading with + today's date. Leave a fresh empty `## [Unreleased]` section above it. + +2. **Bump the version in BOTH places.** + - In [`pyproject.toml`](pyproject.toml), set `[project] version = "1.0.0"`. + - In [`src/conclave/__init__.py`](src/conclave/__init__.py), set + `__version__ = "1.0.0"`. + (The distribution name `conclave-cli` is already set — do **not** change it.) + +3. **Commit.** + ```bash + git add CHANGELOG.md pyproject.toml src/conclave/__init__.py + git commit -m "release: v1.0.0" + git push origin main + ``` + +4. **Tag and push the tag.** (A tag alone does NOT publish anything — it only marks + the commit. The Release in the next step is what triggers the workflow.) + ```bash + git tag v1.0.0 + git push origin v1.0.0 + ``` + +5. **Create the GitHub Release.** This is the trigger. + ```bash + gh release create v1.0.0 \ + --title "v1.0.0" \ + --notes-file <(awk '/## \[1.0.0\]/{f=1} /## \[0\./{if(f)exit} f' CHANGELOG.md) + ``` + or use the GitHub UI: **Releases → Draft a new release → choose tag `v1.0.0` → + Publish release**. + + Publishing the Release fires `release.yml`, which: + - **build** — builds the sdist + wheel with `python -m build` and uploads them as + workflow artifacts so publish + sign use the exact same bytes; + - **pypi-publish** — publishes those artifacts to PyPI via OIDC Trusted + Publishing (no token), with PEP 740 attestations attached. This **fails closed** + if the Trusted Publisher from section 0 is not yet configured; + - **sign** — signs the sdist + wheel with Sigstore keyless and attaches the + `.sigstore` bundle(s) to the GitHub Release assets. + +--- + +## 2. Post-release verification + +1. **Install from PyPI** (give the CDN a minute): + ```bash + pip install conclave-cli + conclave --help + conclave providers # the version is printed in this command's footer + python -c "import conclave; print(conclave.__version__)" + ``` + The install name is `conclave-cli`, the command is `conclave`, the import is + `conclave`. The `python -c` line must print `1.0.0` (there is no `--version` + flag; the running version is shown in the `conclave providers` footer). + Remember to bump `__version__` in `src/conclave/__init__.py` to `1.0.0` in the + release commit (step 1.2) alongside `pyproject.toml`. + +2. **Verify the Sigstore bundle.** On the GitHub Release page, confirm there is a + `.sigstore` (bundle) asset next to each `.tar.gz`/`.whl`. The `sign` job already + self-verified against this workflow's own identity before attaching, but you can + re-verify any artifact locally: + ```bash + pip install sigstore + sigstore verify identity dist/conclave_cli-1.0.0-py3-none-any.whl \ + --bundle conclave_cli-1.0.0-py3-none-any.whl.sigstore \ + --cert-identity \ + "https://github.com/ernestprovo23/conclave/.github/workflows/release.yml@refs/tags/v1.0.0" \ + --cert-oidc-issuer "https://token.actions.githubusercontent.com" + ``` + (Download the `.whl` and its `.sigstore` bundle from the Release assets first.) + +3. **Confirm the PyPI page.** Visit and check: + - version `1.0.0` is listed; + - the project URLs (homepage / repository) point at `ernestprovo23/conclave`; + - "Publisher" shows the Trusted Publisher (OIDC), not a token upload; + - PEP 740 attestations are present (the verified-publish badge). + +--- + +## 3. Rollback / yank + +PyPI uploads are **immutable** — you cannot overwrite a published version. If a +release is broken: + +- **Yank** the bad version (keeps existing pins working, hides it from new + installs): on → **Manage → Releases → + Options → Yank**. Yanking is reversible. +- **Ship a fix-forward release** (`1.0.1`) following section 1 again. This is the + preferred remedy — never try to re-upload `1.0.0`. +- **GitHub Release**: you may delete or edit the GitHub Release and its assets + freely; that does not affect what is already on PyPI. Re-running the workflow + against the same version will fail the PyPI publish (duplicate filename), which is + the correct fail-closed behavior — bump the version instead. + +--- + +## CI security gates (context for releasers) + +- **pip-audit** runs in CI (the `audit` job in `.github/workflows/test.yml`) and is + **fail-closed**: a known vulnerability in any resolved dependency fails CI. + conclave's dependency surface is tiny (`httpx` plus a few well-maintained libs), + so false-positive churn is low. If a transitive CVE with no available fix blocks an + unrelated PR, suppress it narrowly with `pip-audit --ignore-vuln ` + in the workflow step and leave a tracking note in the PR; remove the suppression + once a fixed version is available. +- **requirements-dev.lock** is a hash-pinned lockfile of the full dev + runtime tree, + generated with: + ```bash + uv pip compile --universal --generate-hashes --python-version 3.11 \ + --extra dev pyproject.toml -o requirements-dev.lock + ``` + Regenerate it whenever you change dependencies in `pyproject.toml` so reproducible + installs stay in sync. + +--- + +## Why this design + +- **No stored secret.** OIDC Trusted Publishing means GitHub never holds a PyPI + token; PyPI trusts the workflow identity directly. Same trust model as the keyless + Sigstore signing job. +- **Signed releases.** From v1.0.0 conclave signs its own release artifacts (the + `sign` job) with Sigstore keyless, so consumers can verify the wheel they install + came from this repo's release workflow. PEP 740 attestations on the PyPI upload add + a second, PyPI-native provenance signal. +- **Explicit gesture.** A pushed tag does nothing; only *publishing a Release* ships. + That keeps accidental tags from triggering a publish. diff --git a/SECURITY.md b/SECURITY.md index c8519cb..52eb210 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,6 +7,155 @@ handling: a weakness that causes a real API key to be stored, logged, serialized or echoed back to the user breaks the core trust promise. We treat reports against that surface as the highest priority. +## Threat model + +This section is the honest, current map of what conclave's key handling **does** +and **does not** defend against. It backs the headline BYO-keys claim. The threat +we model is **credential leakage**: a real provider API key escaping the process +into a result object, a log line, a serialized payload, an on-disk cache file, or +terminal output. Accuracy here is the product — we document accepted limitations +rather than overclaim. + +### Trust boundary + +The boundary conclave defends is **"the user's key value never leaves the in-flight +HTTPS request to the provider, except where the user themselves directs it."** + +``` + environment ──(read by NAME, at call time)──▶ adapter builds request + │ │ + │ headers carry the key + │ ▼ + └────────────── INSIDE the boundary ──▶ httpx → provider (TLS) + │ + ════════════ TRUST BOUNDARY ══════════════ │ response / error + ▼ + redact() scrubs every error/diagnostic string conclave produces + │ + ▼ + CouncilResult / logs / cache / stdout ◀── OUTSIDE the boundary (must be key-free) +``` + +Inside the boundary the key value legitimately exists: in the environment, in the +local variable that reads it, and in the request headers handed to httpx. Outside +the boundary — anything conclave returns, logs, caches, serializes, or prints — +must be free of key material. Everything below is about keeping that second set +clean. + +### What IS protected + +- **Name-only key handling.** Keys are referenced by env var **name** in config + and code. The value is read from the environment **at call time** in + `providers._resolve_key`, used only to build the request, and **never assigned + to any object, cached field, or model**. `registry.key_present` / + `key_source` report only *whether* a var is set and its *name* — never its value. +- **`redact()` scope.** Every error/diagnostic string conclave surfaces passes + through `conclave.adapters.base.redact()` before it reaches a result field, a + log, or stdout. `redact()` scrubs, in order: (1) the live **value** of every env + var conclave knows a name for — built-in providers **and** custom-endpoint + `env_var` names declared in config (this catches a BYO key of *any* shape); + (2) `x-api-key` / `x-goog-api-key` header echoes; (3) `Authorization: Bearer …` + tokens; (4) standalone provider-shaped key tokens (`sk-…`, `xai-…`, `pplx-…`, + `AIza…`). `ProviderError` redacts **on construction**; the provider call path + redacts again at capture (belt-and-suspenders). +- **No key persistence — including the cache.** conclave never writes a key to + disk. The optional result cache (`conclave.cache`, off by default) stores only + the already-redacted `CouncilResult` (`model_dump(mode="json")`), and its cache + key is a SHA-256 over prompt + mode + member/synthesizer **names** + model ids + + params — **no env var name or value** is read when computing it. So neither a + cache file, a cache filename, nor the cache key can carry a secret. +- **Streaming path.** Streamed text deltas carry only parsed answer **content**. + A mid-stream provider error is captured and **redacted** on the final + `ModelAnswer` exactly like the buffered path; the error path emits no text + delta, so a key echoed in an error reaches neither a streamed event nor the + final answer. +- **Partial-failure isolation.** One member failing never aborts a run, and each + member's error is independently redacted, so a leak in one provider's error + response cannot smear into another member's result. The defense-in-depth + catch-alls in `Council.fan_out` and `streaming._drive_member` (which only fire on + an *unexpected* raise escaping the already-redacting provider call) also run + their exception text through `redact()`, so the "every surfaced error string is + scrubbed" invariant holds even on those paths. +- **`repr` / `str` safety.** No config, adapter, or result object stores a key, so + none can render one in a `repr`/`str` or a traceback frame that references it. + The transient request `headers` dict does carry the key (it must, to authenticate), + but it is built inside the adapter and handed straight to the transport — it is + not retained on any object. +- **Exception cause-chain hardening.** The transport raises its `TransportError` + with the cause chain dropped, so the surfaced error retains **no** reference to + the underlying httpx exception. That httpx exception's `.request.headers` holds + the live auth header; had it survived as `__cause__`/`__context__` it would be one + cause-chain hop from the surfaced error and would leak the key via + `traceback.format_exception`, `logging.exception`, or a cause-chain `repr` of a + transport error. `raise … from None` clears `__cause__` and sets + `__suppress_context__` (so no standard formatter renders the httpx exception or + its headers), and the transport additionally nulls `__context__` at a boundary so + even a direct `err.__context__` attribute walk finds no header-bearing exception. +- **CLI.** `conclave providers` prints key **presence** and the env var **name**, + never a value; `--json` serializes the same redacted `CouncilResult`. + +These guarantees are pinned by the regression suite in +[`tests/test_keyleak_audit.py`](tests/test_keyleak_audit.py) (one test class per +vector below), plus the redaction/cache/streaming tests in `tests/test_providers.py`, +`tests/test_cache.py`, and `tests/test_streaming.py`. + +### What redact() does NOT cover — accepted limitations + +`redact()` is a defense for the strings **conclave itself** produces. It is not a +universal egress filter, and we do not claim it is. Known gaps, accepted for 1.0: + +- **httpx / httpcore DEBUG logging (out of band — guarded by default).** httpx and + httpcore have their own loggers. At **DEBUG** level httpcore logs full request + headers, including the `Authorization` / `x-api-key` value, to whatever handler + the host application configured. This bypasses `redact()` entirely — it never + sees those records. The guard against it is now **default-on, opt-out**: + constructing a `Council` automatically calls `conclave.guard_transport_logging()`, + which installs a filter that drops httpx/httpcore **DEBUG** records (the only + level that emits header content) while leaving INFO+ diagnostics intact. The + guard is scoped to the `httpx`/`httpcore` loggers only — it never touches the + host application's root logger or any other logger. Opt out with + `Council(…, allow_transport_debug_logging=True)` for the rare case where you + need that DEBUG band in a process that holds no real keys; you remain responsible + for it then. Consumers using the provider functions directly **without** a + `Council` can install the same guard by calling `conclave.guard_transport_logging()` + once at startup (it is idempotent). Either way, the standing guidance remains: + do not enable httpx/httpcore DEBUG logging in a process that holds real provider + keys (e.g. avoid `logging.basicConfig(level=logging.DEBUG)` process-wide). +- **Partial / URL-encoded / transformed key fragments.** `redact()` masks the + exact env-var value and a fixed set of known key *shapes*. It does **not** catch + a key that a provider has split, truncated, URL-encoded, base64-wrapped, or + otherwise transformed before echoing it back, **unless** that transformed form + still equals the live env-var value (the value-based pass) or matches a known + shape. A novel provider error that leaks `…` of a key, or a + percent-encoded form, can slip the pattern pass. The value-based pass is the + primary defense; the shape patterns are best-effort secondary. +- **Anything the user explicitly logs or prints.** If a consumer reads a key from + the environment themselves, or logs/prints the request headers, the raw + `os.environ`, or their own constructed Authorization header, that is outside + conclave's control. conclave only governs the strings it returns and logs. +- **The in-flight request and the provider side.** The key is, by necessity, + present in the request headers and transmitted to the provider over TLS. What the + provider does with it (its logs, its breach posture) is outside scope. Memory + inspection of the running process (a local attacker with debugger access) is also + out of scope — the env var value is in process memory by design. +- **Dependencies.** Vulnerabilities in httpx, pydantic, typer, pyyaml, or rich + themselves are upstream; report conclave's *use* of them to us if exploitable, + but the libraries' own CVEs belong upstream. CI runs gitleaks on every push. + +### Key-leak vector map (what a reviewer probes day 1) + +| # | Vector | Risk | Status | +|---|--------|------|--------| +| 1 | Cache write path ordering | HIGH if pre-redaction | **Protected** — cache stores only the redacted `CouncilResult`; key never in file/filename/key. Test: V1. | +| 2 | Streaming chunk path | MED | **Protected** — deltas are answer content; mid-stream errors redacted on the final answer, never streamed. Test: V2. | +| 3 | config/transport `__repr__` in tracebacks | MED | **Protected** — no object stores a key; transient headers are not retained. Test: V3. | +| 4 | Provider 400/422 echoing request fragments | MED | **Protected** — error capture runs through `redact()` (and `ProviderError` redacts on construction). Test: V4. | +| 5 | httpx/httpcore DEBUG logging | HIGH (bypasses redact) | **Default-on guard (Council installs it) + opt-out** — `Council.__init__` calls `guard_transport_logging()` automatically; opt out with `allow_transport_debug_logging=True`. Tests: V5, V9. | +| 6 | redact() misses URL-encoded / partial fragments | MED | **Accepted limitation** — documented above; value-based pass is primary, shape patterns best-effort. | +| 7 | Test fixtures with key-shaped strings | LOW | **Protected** — all fixtures use obviously-fake `…FAKE…` patterns; `.gitleaks.toml` allowlists the test tree only. | +| 8 | Partial-failure catch-all error construction (audit-found; not in original map) | LOW | **Protected** — `fan_out` / `_drive_member` catch-alls now `redact()` the raw exception text too. Test: V7. | +| 9 | TransportError cause chain retaining the httpx exception (header-bearing `.request`) | HIGH (leaks via traceback/`logging.exception`) | **Protected** — transport raises `… from None`; surfaced error keeps no `__cause__`/`__context__` ref to the httpx exception, so its auth header cannot leak via traceback/cause-chain repr. Test: V8. | + ## Reporting a vulnerability **Do not open a public GitHub issue, pull request, or discussion for a security diff --git a/pyproject.toml b/pyproject.toml index 653c17e..467b662 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,13 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "conclave" -version = "0.3.0" +# PyPI distribution name is `conclave-cli` because the PyPI name `conclave` is +# taken by an unrelated project (a blockchain client, not this one). The CLI +# command (`conclave`), the import package (`conclave`), and the repo all stay +# `conclave` — only the published *distribution* name differs. So users +# `pip install conclave-cli`, then run `conclave ...` / `from conclave import Council`. +name = "conclave-cli" +version = "1.0.0" description = "Bring-your-own-keys multi-model council CLI + library. Fan a prompt to N foundation models concurrently and merge their answers." readme = "README.md" requires-python = ">=3.11" @@ -20,6 +25,12 @@ dependencies = [ "pyyaml>=6.0", ] +[project.urls] +Homepage = "https://github.com/ernestprovo23/conclave" +Repository = "https://github.com/ernestprovo23/conclave" +Issues = "https://github.com/ernestprovo23/conclave/issues" +Changelog = "https://github.com/ernestprovo23/conclave/blob/main/CHANGELOG.md" + [project.optional-dependencies] dev = [ "pytest>=8.0.0", diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..9b45100 --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,472 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --universal --generate-hashes --python-version 3.11 --extra dev pyproject.toml -o requirements-dev.lock +annotated-doc==0.0.4 \ + --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \ + --hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4 + # via typer +annotated-types==0.7.0 \ + --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ + --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 + # via pydantic +anyio==4.13.0 \ + --hash=sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708 \ + --hash=sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc + # via httpx +certifi==2026.5.20 \ + --hash=sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897 \ + --hash=sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d + # via + # httpcore + # httpx +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via + # pytest + # typer +coverage==7.14.1 \ + --hash=sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86 \ + --hash=sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd \ + --hash=sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d \ + --hash=sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5 \ + --hash=sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42 \ + --hash=sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de \ + --hash=sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548 \ + --hash=sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1 \ + --hash=sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7 \ + --hash=sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59 \ + --hash=sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906 \ + --hash=sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af \ + --hash=sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1 \ + --hash=sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d \ + --hash=sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1 \ + --hash=sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be \ + --hash=sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02 \ + --hash=sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42 \ + --hash=sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129 \ + --hash=sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e \ + --hash=sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be \ + --hash=sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e \ + --hash=sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65 \ + --hash=sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54 \ + --hash=sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1 \ + --hash=sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5 \ + --hash=sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df \ + --hash=sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47 \ + --hash=sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f \ + --hash=sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf \ + --hash=sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37 \ + --hash=sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4 \ + --hash=sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f \ + --hash=sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84 \ + --hash=sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1 \ + --hash=sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c \ + --hash=sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8 \ + --hash=sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e \ + --hash=sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec \ + --hash=sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d \ + --hash=sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54 \ + --hash=sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890 \ + --hash=sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b \ + --hash=sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d \ + --hash=sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2 \ + --hash=sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33 \ + --hash=sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9 \ + --hash=sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e \ + --hash=sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6 \ + --hash=sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce \ + --hash=sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247 \ + --hash=sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901 \ + --hash=sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36 \ + --hash=sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69 \ + --hash=sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416 \ + --hash=sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5 \ + --hash=sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500 \ + --hash=sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad \ + --hash=sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1 \ + --hash=sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b \ + --hash=sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b \ + --hash=sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a \ + --hash=sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e \ + --hash=sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee \ + --hash=sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07 \ + --hash=sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a \ + --hash=sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d \ + --hash=sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c \ + --hash=sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343 \ + --hash=sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4 \ + --hash=sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2 \ + --hash=sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8 \ + --hash=sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf \ + --hash=sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb \ + --hash=sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c \ + --hash=sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff \ + --hash=sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e \ + --hash=sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550 \ + --hash=sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860 \ + --hash=sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793 \ + --hash=sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f \ + --hash=sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851 \ + --hash=sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7 \ + --hash=sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332 \ + --hash=sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b \ + --hash=sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2 \ + --hash=sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d \ + --hash=sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a \ + --hash=sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef \ + --hash=sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474 \ + --hash=sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee \ + --hash=sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43 \ + --hash=sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034 \ + --hash=sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3 \ + --hash=sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c \ + --hash=sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d \ + --hash=sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7 \ + --hash=sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e \ + --hash=sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d \ + --hash=sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4 \ + --hash=sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9 \ + --hash=sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52 \ + --hash=sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a \ + --hash=sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c \ + --hash=sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253 \ + --hash=sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c + # via pytest-cov +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via conclave-cli (pyproject.toml) +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via rich +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via pytest +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via + # pytest + # pytest-cov +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ + --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 + # via conclave-cli (pyproject.toml) +pydantic-core==2.46.4 \ + --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ + --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ + --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \ + --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \ + --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ + --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ + --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ + --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ + --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ + --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ + --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ + --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ + --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ + --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ + --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ + --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ + --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ + --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ + --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ + --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ + --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ + --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ + --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ + --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ + --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ + --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ + --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ + --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ + --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ + --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ + --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ + --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ + --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ + --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ + --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ + --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ + --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ + --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ + --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ + --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \ + --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \ + --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \ + --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \ + --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \ + --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \ + --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \ + --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \ + --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \ + --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \ + --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \ + --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ + --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ + --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ + --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ + --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ + --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ + --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ + --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ + --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ + --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ + --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ + --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ + --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ + --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ + --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \ + --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \ + --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ + --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ + --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ + --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ + --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ + --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ + --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ + --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ + --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ + --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ + --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ + --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ + --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ + --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ + --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ + --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ + --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ + --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ + --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ + --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \ + --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \ + --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \ + --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \ + --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ + --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ + --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ + --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ + --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ + --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ + --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ + --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ + --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ + --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ + --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ + --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ + --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ + --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ + --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ + --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ + --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ + --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ + --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ + --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ + --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ + --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + # via pydantic +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via + # pytest + # rich +pytest==9.1.0 \ + --hash=sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c \ + --hash=sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32 + # via + # conclave-cli (pyproject.toml) + # pytest-asyncio + # pytest-cov +pytest-asyncio==1.4.0 \ + --hash=sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1 \ + --hash=sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42 + # via conclave-cli (pyproject.toml) +pytest-cov==7.1.0 \ + --hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \ + --hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 + # via conclave-cli (pyproject.toml) +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via conclave-cli (pyproject.toml) +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 + # via + # conclave-cli (pyproject.toml) + # typer +shellingham==1.5.4 \ + --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \ + --hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de + # via typer +tomli==2.4.1 ; python_full_version <= '3.11' \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 + # via coverage +typer==0.26.7 \ + --hash=sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58 \ + --hash=sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a + # via conclave-cli (pyproject.toml) +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 + # via + # anyio + # pydantic + # pydantic-core + # pytest-asyncio + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via pydantic diff --git a/src/conclave/__init__.py b/src/conclave/__init__.py index 110447e..17735bc 100644 --- a/src/conclave/__init__.py +++ b/src/conclave/__init__.py @@ -49,9 +49,9 @@ StreamEvent, TokenUsage, ) -from .transport import aclose +from .transport import aclose, guard_transport_logging -__version__ = "0.3.0" +__version__ = "1.0.0" __all__ = [ "Council", @@ -64,5 +64,6 @@ "ConclaveConfig", "load_config", "aclose", + "guard_transport_logging", "__version__", ] diff --git a/src/conclave/cache.py b/src/conclave/cache.py index 455e303..71be1b9 100644 --- a/src/conclave/cache.py +++ b/src/conclave/cache.py @@ -195,6 +195,17 @@ def store(key: str, result: CouncilResult) -> None: try: path = _entry_path(key) path.parent.mkdir(parents=True, exist_ok=True) + # KEY-LEAK INVARIANT (audit vector 1): the cache only ever persists a + # CouncilResult that has ALREADY passed through redaction upstream. Every + # error string on the result (ModelAnswer.error, synthesis_error, + # verdict_error) is scrubbed by redact() at the point of capture in + # conclave.providers, BEFORE it is placed on the result and therefore long + # before it reaches this write. Member/synthesis answer TEXT is provider + # content, never key material. The cache KEY (make_key) is composed solely + # of prompt + mode + member/synthesizer NAMES + model ids + params -- no + # env var name or value is read here. Net: no raw key (name or value) can + # reach a cache file or filename. Do not move any un-redacted capture into + # the result after this contract -- it would persist a secret to disk. payload = result.model_dump(mode="json") payload["cached"] = False # Atomic-ish write: write to a temp sibling then replace, so a crash mid diff --git a/src/conclave/council.py b/src/conclave/council.py index 6f7508f..101827e 100644 --- a/src/conclave/council.py +++ b/src/conclave/council.py @@ -8,6 +8,45 @@ The deliberation modes (``debate``, ``adversarial``) live in :mod:`conclave.modes` and reuse this class's :meth:`Council.fan_out` primitive so the partial-failure handling is written exactly once. + +Synthesizer selection and degradation (the "council" value prop) +---------------------------------------------------------------- + +**Which model synthesizes.** Synthesis is performed by one *synthesizer* model, +separate from the council members (though a member may also be the synthesizer). +Selection precedence, highest first: + +1. the ``synthesizer=`` argument to :class:`Council` (the CLI ``--synthesizer/-s`` + flag wires straight through to this); +2. the ``synthesizer:`` key in ``~/.conclave/config.yml``; +3. the built-in default :data:`conclave.registry.DEFAULT_SYNTHESIZER` (``"claude"``, + i.e. ``anthropic/claude-sonnet-4-6``). + +The same model is the **judge** in ``adversarial`` mode and the final +consolidator in ``debate`` mode -- one selection drives all three. + +**The fallback / degraded path is OBSERVABLE, never silent.** Synthesis can fail +to run for three reasons, and each one is signaled on the result rather than +silently swallowed: + +* *No usable member answers* (every member errored/skipped) -- nothing to merge; +* *The synthesizer has no API key* in the environment; +* *The synthesizer call itself fails* (provider error/timeout). + +In all three cases ``CouncilResult.synthesis`` stays ``None``, the member answers +are still returned intact, a warning is logged, and an actionable reason is set +on ``CouncilResult.synthesis_error`` (in ``adversarial`` mode the analogous +``AdversarialResult.verdict_error``, mirrored to ``synthesis_error``). A caller +can therefore always tell synthesis did **not** happen as expected by checking +``synthesis is None and synthesis_error is not None`` -- there is no path where +the council quietly returns concatenated/partial output dressed up as a synthesis. + +**The synthesis prompt is a versioned constant.** The synthesize-mode system +prompt is :data:`_SYNTH_SYSTEM` (the debate/judge prompts live in +:mod:`conclave.prompts`); the prompt *set* carries the version tag +:data:`conclave.prompts.SYNTHESIS_PROMPT_VERSION`, stamped onto every +:class:`~conclave.models.CouncilResult` as ``prompt_version`` so a prompt change +is detectable downstream instead of being silently absorbed as model drift. """ from __future__ import annotations @@ -17,9 +56,11 @@ from . import cache as cache_mod from . import transport +from .adapters.base import redact from .config import ConclaveConfig, load_config from .logging import get_logger from .models import CouncilResult, ModelAnswer, StreamEvent +from .prompts import SYNTHESIS_PROMPT_VERSION from .providers import call_model from .registry import key_present @@ -30,6 +71,14 @@ # per member while sharing Council.fan_out's concurrency + partial-failure code. MessagesFor = Callable[[str, str], list[dict[str, str]]] +# The synthesize-mode system prompt. It is a stable module constant -- never +# built per-call -- so the wording the council synthesizes under is auditable and +# diffable. Any change to it (or to the debate/judge prompts in +# :mod:`conclave.prompts`) MUST be paired with a bump of +# :data:`conclave.prompts.SYNTHESIS_PROMPT_VERSION`, which is stamped onto every +# :class:`~conclave.models.CouncilResult` as ``prompt_version`` so a downstream +# eval can detect the change rather than silently absorb it. ``test_synthesizer`` +# pins both this text and the version, so editing one without the other fails CI. _SYNTH_SYSTEM = ( "You are the synthesizer of a council of AI models. You are given the same " "user prompt that was posed to several models, plus each model's answer. " @@ -38,6 +87,9 @@ "Do not invent a model's position; rely only on the answers provided." ) +# Re-exported for callers that want the version without importing prompts. +__all__ = ["Council", "SYNTHESIS_PROMPT_VERSION"] + class Council: """A council of foundation models with an optional synthesizer. @@ -55,6 +107,20 @@ class Council: identical repeat run is served from the on-disk cache instead of re-calling the providers. The cache never persists API keys -- see :mod:`conclave.cache`. + allow_transport_debug_logging: Opt **out** of the transport-logging guard. + Defaults to ``False``, which means the guard is **ON**: constructing a + ``Council`` installs :func:`conclave.transport.guard_transport_logging` + so httpx/httpcore ``DEBUG`` records -- the only band that emits request + headers, including the live ``Authorization``/``x-api-key`` value -- are + dropped before any handler formats them (key-leak audit, RANK 6). The + guard is idempotent, so constructing many councils installs it once. The + filter is scoped to the ``httpx``/``httpcore`` loggers only; it never + touches the host application's root logger or any other logger. + Set ``True`` to skip installation for the rare case where you genuinely + need httpx/httpcore ``DEBUG`` output in a process that does not hold real + keys; you remain responsible for that band then. Consumers using the + provider functions directly (without a ``Council``) can still call + :func:`conclave.guard_transport_logging` themselves. Example: >>> council = Council(models=["grok", "perplexity"], synthesizer="claude") @@ -70,6 +136,7 @@ def __init__( temperature: float = 0.7, timeout: float = 120.0, cache: bool | None = None, + allow_transport_debug_logging: bool = False, ) -> None: self.config = config or load_config() self.requested_models = list(models) @@ -78,6 +145,15 @@ def __init__( self.timeout = timeout # Explicit override wins; otherwise defer to config (off by default). self.cache_enabled = self.config.cache if cache is None else cache + # Default-on transport-logging guard (key-leak audit, RANK 6): drop + # httpx/httpcore DEBUG records (the only band that emits the auth header) + # so a process holding a real key cannot leak it via verbose transport + # logging, even if the host enables DEBUG app-wide. Idempotent, so many + # councils install it once; scoped to the httpx/httpcore loggers only. + # ``allow_transport_debug_logging=True`` opts out for callers who need + # that DEBUG band and accept the responsibility. + if not allow_transport_debug_logging: + transport.guard_transport_logging() def _available_members(self) -> tuple[list[tuple[str, str]], list[str]]: """Partition requested members into (available, skipped-for-no-key). @@ -205,14 +281,13 @@ async def fan_out( if isinstance(outcome, ModelAnswer): answers.append(outcome) else: - logger.warning("%s raised unexpectedly: %s", name, outcome) - answers.append( - ModelAnswer( - name=name, - model_id=model_id, - error=f"{type(outcome).__name__}: {outcome}", - ) - ) + # call_model already redacts and never raises, so this arm only + # fires on an UNEXPECTED escape. Redact the exception text anyway: + # the invariant "every error string conclave surfaces is scrubbed" + # must hold even on this defense-in-depth path (key-leak audit). + message = redact(f"{type(outcome).__name__}: {outcome}") + logger.warning("%s raised unexpectedly: %s", name, message) + answers.append(ModelAnswer(name=name, model_id=model_id, error=message)) return answers async def ask(self, prompt: str, synthesize: bool = True) -> CouncilResult: @@ -358,7 +433,32 @@ def _replay_cached(result: CouncilResult) -> list[StreamEvent]: return events async def _synthesize(self, result: CouncilResult) -> None: - """Run the synthesizer over the successful answers, mutating ``result``.""" + """Run the synthesizer over the successful answers, mutating ``result``. + + This is the buffered (non-streaming) synthesize path; the streaming + counterpart :func:`conclave.streaming._stream_synthesis` mirrors it + short-circuit for short-circuit. The synthesizer model is + ``self.synthesizer`` (resolved per the precedence documented in the module + docstring: constructor arg, else config, else the ``"claude"`` default). + + Every degraded outcome is made observable on ``result`` -- none is + silent. On success ``result.synthesis`` holds the merged answer; on any + of the three short-circuits ``result.synthesis`` stays ``None`` and + ``result.synthesis_error`` carries the reason: + + * **no usable answers** -- every member failed/was skipped, so there is + nothing to merge; + * **synthesizer unkeyed** -- ``self.synthesizer``'s API key is absent, so + the raw member answers are returned with an explanatory error; + * **synthesizer call failed** -- the synthesizer provider errored, and its + error text is surfaced verbatim. + + The synthesizer identity (``synthesizer`` / ``synthesizer_model_id``) is + recorded on ``result`` before the key check so a consumer can see *which* + model was selected even when it could not run. The prompt used is the + versioned :data:`_SYNTH_SYSTEM`; the version tag already lives on + ``result.prompt_version``. + """ usable = result.successful_answers if not usable: result.synthesis_error = "no successful member answers to synthesize" diff --git a/src/conclave/models.py b/src/conclave/models.py index dec2693..bf5ba11 100644 --- a/src/conclave/models.py +++ b/src/conclave/models.py @@ -9,6 +9,19 @@ from pydantic import BaseModel, Field +def _default_prompt_version() -> str: + """Resolve the current synthesis-prompt version without an import cycle. + + ``conclave.prompts`` imports this module, so importing it at module load + would be circular. The import is deferred into this factory (run only when a + ``CouncilResult`` is constructed, by which point both modules are loaded), so + every result defaults to the live :data:`conclave.prompts.SYNTHESIS_PROMPT_VERSION`. + """ + from .prompts import SYNTHESIS_PROMPT_VERSION + + return SYNTHESIS_PROMPT_VERSION + + class TokenUsage(BaseModel): """Token accounting for a single model call.""" @@ -164,6 +177,13 @@ class CouncilResult(BaseModel): convergence_score: The convergence score (0.0--1.0) of the round that triggered an early stop, or ``None`` when no early stop occurred. Higher means more stable round-over-round (more converged). + prompt_version: The version tag of the synthesizer/judge prompt set used + for this run (:data:`conclave.prompts.SYNTHESIS_PROMPT_VERSION`). + Stamped on **every** result regardless of mode or whether synthesis + actually ran, so a downstream eval/regression suite can detect that + the synthesis prompt wording changed between two runs instead of + silently attributing the shift to model drift. Opaque string; only + equality is meaningful. """ prompt: str @@ -179,6 +199,7 @@ class CouncilResult(BaseModel): cached: bool = False converged: bool = False convergence_score: float | None = None + prompt_version: str = Field(default_factory=_default_prompt_version) @property def successful_answers(self) -> list[ModelAnswer]: diff --git a/src/conclave/prompts.py b/src/conclave/prompts.py index 7ce5b38..dc3ea07 100644 --- a/src/conclave/prompts.py +++ b/src/conclave/prompts.py @@ -10,6 +10,17 @@ from .models import ModelAnswer +# Version identifier for the synthesis/judge prompt *set*. Bump this string +# whenever ANY synthesizer-facing prompt changes -- the synthesize-mode system +# prompt (``conclave.council._SYNTH_SYSTEM``), the debate consolidation prompt +# (:data:`DEBATE_FINAL_SYSTEM`), or the adversarial judge prompt +# (:data:`JUDGE_SYSTEM`). It is surfaced on :class:`conclave.models.CouncilResult` +# (the ``prompt_version`` field) so a downstream eval or regression suite can +# detect that the wording the synthesis was produced under has shifted, rather +# than silently absorbing a prompt change as a quality regression. The value is +# opaque (a date-stamped tag); only equality/inequality is meaningful. +SYNTHESIS_PROMPT_VERSION = "2026-06-14" + # Stable position-based labels used to anonymize peers in debate rounds 2..N. LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" diff --git a/src/conclave/streaming.py b/src/conclave/streaming.py index e943567..599ec37 100644 --- a/src/conclave/streaming.py +++ b/src/conclave/streaming.py @@ -31,6 +31,7 @@ from collections.abc import AsyncIterator from typing import TYPE_CHECKING +from .adapters.base import redact from .logging import get_logger from .models import CouncilResult, ModelAnswer, StreamEvent from .providers import call_model_stream @@ -81,15 +82,16 @@ async def _drive_member( ) ) except Exception as exc: # noqa: BLE001 -- a member must never wedge the run - logger.warning("%s streaming raised unexpectedly: %s", name, exc) + # call_model_stream already redacts and never raises, so this arm only + # fires on an UNEXPECTED escape. Redact the exception text anyway so the + # "every surfaced error string is scrubbed" invariant holds even on this + # defense-in-depth path (key-leak audit, vector 2). + message = redact(f"{type(exc).__name__}: {exc}") + logger.warning("%s streaming raised unexpectedly: %s", name, message) await queue.put( ( "answer", - ModelAnswer( - name=name, - model_id=model_id, - error=f"{type(exc).__name__}: {exc}", - ), + ModelAnswer(name=name, model_id=model_id, error=message), ) ) finally: diff --git a/src/conclave/transport.py b/src/conclave/transport.py index 1107984..ea16f36 100644 --- a/src/conclave/transport.py +++ b/src/conclave/transport.py @@ -13,7 +13,9 @@ from __future__ import annotations +import logging from collections.abc import AsyncIterator +from typing import NoReturn import httpx @@ -25,6 +27,69 @@ # within a process. httpx.AsyncClient is safe to share across concurrent tasks. _client: httpx.AsyncClient | None = None +# --------------------------------------------------------------------------- # +# httpx/httpcore debug-logging leak guard (key-leak audit, vector 5) +# --------------------------------------------------------------------------- # +# +# SECURITY-CRITICAL, OUT-OF-BAND OF redact(): httpx and httpcore have their own +# `logging` loggers. At DEBUG level httpcore logs the full request headers -- +# which include ``Authorization: Bearer `` and ``x-api-key: `` -- to +# whatever handler the host application configured. conclave's ``redact()`` only +# scrubs the error/diagnostic strings *it* produces; it cannot reach inside the +# third-party transport loggers. So a consumer that turns on transport DEBUG +# logging (``logging.basicConfig(level=logging.DEBUG)`` app-wide, or explicitly +# raising the httpx/httpcore loggers) would leak live keys to their own logs, +# entirely bypassing every redaction conclave performs. +# +# We cannot (and should not) globally silence another library's logging for the +# whole process -- that would be surprising and could hide legitimate debugging. +# Instead we expose an explicit, opt-in guard a security-conscious library +# consumer can call once at startup. It installs a filter that drops any +# httpx/httpcore log record at DEBUG severity (the only level that emits header +# content), while leaving INFO+ records untouched. See SECURITY.md "Threat +# model" for the documented trust boundary and accepted limitation. +_TRANSPORT_LOGGER_NAMES = ("httpx", "httpcore") +_GUARD_INSTALLED = False + + +class _NoDebugHeadersFilter(logging.Filter): + """Drop DEBUG-level records from a transport logger (where headers appear). + + httpcore emits request/response headers only at ``DEBUG``; INFO and above + carry no header content. Filtering exactly the DEBUG band stops the header + leak without suppressing useful higher-severity transport diagnostics. + """ + + def filter(self, record: logging.LogRecord) -> bool: + # Returning False discards the record before any handler formats it. + return record.levelno > logging.DEBUG + + +def guard_transport_logging() -> None: + """Block httpx/httpcore DEBUG logging so auth headers can never be logged. + + **Opt-in, library-mode key-leak hardening.** httpx/httpcore log full request + headers (including the ``Authorization``/``x-api-key`` value) at ``DEBUG``. + That path is outside :func:`conclave.adapters.base.redact`'s reach, so a host + application that enables transport DEBUG logging would leak live keys to its + own log sinks. Calling this once at startup installs a logging filter on the + ``httpx`` and ``httpcore`` loggers that discards their DEBUG records, closing + the leak while leaving INFO+ diagnostics intact. Idempotent. + + This is intentionally **not** called automatically: silently reconfiguring a + third-party library's logging for the whole process would be surprising and + could mask legitimate debugging. A consumer that handles real keys and also + runs verbose transport logging should call it explicitly. The default, + documented guidance (SECURITY.md) is simply: do not enable httpx/httpcore + DEBUG logging in a process that holds real provider keys. + """ + global _GUARD_INSTALLED + if _GUARD_INSTALLED: + return + for name in _TRANSPORT_LOGGER_NAMES: + logging.getLogger(name).addFilter(_NoDebugHeadersFilter()) + _GUARD_INSTALLED = True + class TransportError(Exception): """A network-level failure (timeout, connection refused, DNS, etc.). @@ -32,7 +97,43 @@ class TransportError(Exception): Raised by :func:`post_json` so :func:`conclave.providers.call_model` can turn it into a non-raising ``ModelAnswer.error``. The message is built from the exception type only -- never from request headers -- so it carries no secret. + + KEY-LEAK NOTE (audit RANK 1/5): the raise sites route through + :func:`_raise_transport_error` (``raise ... from None``) and a boundary clear, + so the surfaced TransportError retains **no** reference to the underlying httpx + exception -- not as ``__cause__``, not as ``__context__``. That httpx + exception's ``.request.headers`` carries the live ``Authorization``/``x-api-key`` + value; had it survived it would leak the key one cause-chain hop away under + ``traceback.format_exception``, ``logging.exception``, a ``repr`` of the cause + chain, or a direct ``err.__context__`` attribute walk. Dropping the chain is + deliberate -- the message already names the failure kind, so no diagnostic + value is lost. + """ + + +def _raise_transport_error(message: str) -> NoReturn: + """Raise a :class:`TransportError` that retains no link to the httpx exception. + + KEY-LEAK NOTE (audit RANK 1/5). The httpx exception active when this is called + carries a live ``.request`` whose ``.headers`` hold the ``Authorization`` / + ``x-api-key`` value. We must not let it survive on the surfaced TransportError: + + * ``raise ... from None`` sets ``__cause__ = None`` and + ``__suppress_context__ = True`` -- enough that ``traceback.format_exception``, + ``logging.exception``, and a cause-chain ``repr`` render neither the httpx + exception nor its headers (those formatters honor ``__suppress_context__``). + * Python's implicit-context machinery still points ``__context__`` at the + active httpx exception at ``raise`` time, so a *direct attribute walk* + (``err.__context__.request.headers``) could still reach the key. We therefore + build the error and raise with ``from None`` here; the **caller** clears + ``__context__`` at a boundary where no exception is being handled (so Python + cannot re-chain it), making even a direct walk key-free. + + Centralizing the raise keeps the clear-and-raise contract identical at all four + transport raise sites. The message names only the failure kind, so dropping the + chain loses no diagnostic value. """ + raise TransportError(message) from None def _get_client() -> httpx.AsyncClient: @@ -65,17 +166,33 @@ async def post_json( Raises: TransportError: On any network-level failure (timeout, connection error, or other ``httpx.HTTPError``). The message names only the failure - kind and never echoes the headers, so no key can leak. + kind and never echoes the headers, so no key can leak. The underlying + httpx exception is deliberately dropped from the cause chain + (``__cause__`` and ``__context__`` both cleared) so its header-bearing + ``.request`` cannot leak the key via the surfaced error's traceback, + cause-chain repr, or a direct attribute walk (audit RANK 1/5). """ client = _get_client() + # Inner try maps httpx failures to TransportError via _raise_transport_error + # (which raises ``from None``); the outer try clears ``__context__`` at a + # boundary where no httpx exception is active, so even a direct attribute walk + # finds no header-bearing httpx exception (key-leak audit RANK 1/5). See + # _raise_transport_error for the full rationale. try: - response = await client.post(url, headers=headers, json=json_body, timeout=timeout) - except httpx.TimeoutException as exc: - raise TransportError(f"request timed out after {timeout:.0f}s") from exc - except httpx.HTTPError as exc: - # Use the exception class name, not str(exc): httpx error strings can - # include the request URL but never headers, yet we stay conservative. - raise TransportError(f"network error: {type(exc).__name__}") from exc + try: + response = await client.post(url, headers=headers, json=json_body, timeout=timeout) + except httpx.TimeoutException: + _raise_transport_error(f"request timed out after {timeout:.0f}s") + except httpx.HTTPError as exc: + # Use the exception class NAME, not str(exc): httpx error strings can + # include the request URL but never headers, yet we stay conservative. + _raise_transport_error(f"network error: {type(exc).__name__}") + except TransportError as err: + # Boundary clear: no httpx exception is being handled here, so nulling + # ``__context__`` sticks (Python will not re-chain) and re-raising + # ``from None`` keeps ``__cause__``/``__suppress_context__`` clean. + err.__context__ = None + raise err from None try: body: object = response.json() @@ -129,47 +246,78 @@ async def stream_sse( Raises: TransportError: On any network-level failure (timeout, connection error) or a non-2xx streaming status. The message names only the - failure kind / HTTP status and never echoes the headers. + failure kind / HTTP status and never echoes the headers. The + underlying httpx exception is dropped from the cause chain + (``__cause__`` and ``__context__`` both cleared) so its header-bearing + ``.request`` cannot leak the key via the surfaced error's traceback, + cause-chain repr, or a direct attribute walk (audit RANK 1/5). """ client = _get_client() + # Inner try maps httpx failures to TransportError via _raise_transport_error + # (raises ``from None``); the outer try clears ``__context__`` at a boundary + # where no httpx exception is active, so even a direct attribute walk finds no + # header-bearing httpx exception (key-leak audit RANK 1/5). The intentional + # ``HTTP : `` error (not chained from httpx) also passes the + # boundary harmlessly -- it carries no httpx context to clear. try: - async with client.stream( - "POST", url, headers=headers, json=json_body, timeout=timeout - ) as response: - if response.status_code < 200 or response.status_code >= 300: - # Drain the error body so we can report a useful, bounded detail. - # aread() is required before the response is consumed/closed. - raw = await response.aread() - detail = raw.decode("utf-8", "replace")[:500] - raise TransportError(f"HTTP {response.status_code}: {detail}") - - event_name = "" - data_lines: list[str] = [] - async for line in response.aiter_lines(): - # A blank line terminates the current event -> dispatch it. - if line == "": - if data_lines: - yield event_name, "\n".join(data_lines) - event_name = "" - data_lines = [] - continue - if line.startswith(":"): - # SSE comment / keep-alive ping; ignore. - continue - if line.startswith("event:"): - event_name = line[len("event:") :].strip() - elif line.startswith("data:"): - data_lines.append(line[len("data:") :].lstrip()) - # Any other field (id:, retry:, ...) is irrelevant here. - - # Flush a final event with no trailing blank line (some servers do - # not emit the terminating newline). - if data_lines: - yield event_name, "\n".join(data_lines) - except httpx.TimeoutException as exc: - raise TransportError(f"request timed out after {timeout:.0f}s") from exc - except httpx.HTTPError as exc: - raise TransportError(f"network error: {type(exc).__name__}") from exc + try: + async with client.stream( + "POST", url, headers=headers, json=json_body, timeout=timeout + ) as response: + if response.status_code < 200 or response.status_code >= 300: + # Drain the error body so we can report a useful, bounded detail. + # aread() is required before the response is consumed/closed. + raw = await response.aread() + detail = raw.decode("utf-8", "replace")[:500] + # KEY-LEAK NOTE (audit vector 2/4): this raw provider body may echo + # request fragments. It is intentionally NOT redacted here -- the + # transport stays provider-agnostic and never imports redact(). The + # single redaction boundary for the streaming path is + # conclave.providers.call_model_stream, which wraps every + # TransportError/ProviderError message in redact() before it lands + # on ModelAnswer.error or is logged. No streamed text delta is + # emitted on this path (deltas carry only parsed answer content), + # so the only surface for this string is that redacted final answer. + raise TransportError(f"HTTP {response.status_code}: {detail}") + + event_name = "" + data_lines: list[str] = [] + async for line in response.aiter_lines(): + # A blank line terminates the current event -> dispatch it. + if line == "": + if data_lines: + yield event_name, "\n".join(data_lines) + event_name = "" + data_lines = [] + continue + if line.startswith(":"): + # SSE comment / keep-alive ping; ignore. + continue + if line.startswith("event:"): + event_name = line[len("event:") :].strip() + elif line.startswith("data:"): + data_lines.append(line[len("data:") :].lstrip()) + # Any other field (id:, retry:, ...) is irrelevant here. + + # Flush a final event with no trailing blank line (some servers do + # not emit the terminating newline). + if data_lines: + yield event_name, "\n".join(data_lines) + except httpx.TimeoutException: + # Map to TransportError with the chain dropped (audit RANK 1/5). The + # streaming httpx exception also carries ``.request.headers`` with the + # live auth value; _raise_transport_error raises ``from None``. + _raise_transport_error(f"request timed out after {timeout:.0f}s") + except httpx.HTTPError as exc: + # Drop the httpx exception from the cause chain so its header-bearing + # ``.request`` cannot leak the key (audit RANK 1/5). + _raise_transport_error(f"network error: {type(exc).__name__}") + except TransportError as err: + # Boundary clear: no httpx exception is active here, so nulling + # ``__context__`` sticks (Python will not re-chain) and re-raising + # ``from None`` keeps ``__cause__``/``__suppress_context__`` clean. + err.__context__ = None + raise err from None async def aclose() -> None: diff --git a/tests/test_keyleak_audit.py b/tests/test_keyleak_audit.py new file mode 100644 index 0000000..2e54fa9 --- /dev/null +++ b/tests/test_keyleak_audit.py @@ -0,0 +1,794 @@ +"""Key-leak audit regression suite (conclave v1.0, SECURITY.md threat model). + +These tests back conclave's headline **bring-your-own-keys / key-rigor** claim. +Each test maps to one vector of the key-leak attack map in SECURITY.md's threat +model and the v1.0 readiness review. They plant an OBVIOUSLY-FAKE, key-shaped +secret somewhere a leak could occur and assert it never escapes -- so a security +reviewer (or a future refactor) cannot quietly break the contract. + +All tests run offline. Planted secrets use synthetic ``...FAKE...`` patterns that +gitleaks will not flag (see ``.gitleaks.toml``); no real credential is ever used. + +Vector map +========== +* **V1 cache write path** -- a key-shaped secret echoed in a provider error never + reaches a cache file, the cache key, or the cache filename (cache stores the + already-redacted ``CouncilResult``). +* **V2 streaming chunk path** -- a secret planted in a mid-stream provider error is + absent from every streamed ``StreamEvent`` AND from the final ``ModelAnswer``. +* **V3 __repr__/__str__** -- no config/adapter/result object renders a planted key + in its ``repr``/``str``; the key-holding request ``headers`` dict is built only + inside the adapter and never stored on any object. +* **V4 provider 400/422 echo** -- a buffered HTTP error whose body echoes the + submitted key is scrubbed in ``ModelAnswer.error`` (capture runs through + ``redact()``). +* **V5 transport debug logging** -- the ``guard_transport_logging`` helper blocks + httpx/httpcore DEBUG records (the only level that emits auth headers). +* **V8 transport cause-chain (RANK 1/5)** -- a ``TransportError`` surfaced from + ``post_json``/``stream_sse`` retains no reference to the underlying httpx + exception, whose ``.request.headers`` carries the live auth header; the chain is + dropped (``raise ... from None``) so the key cannot leak via + ``traceback.format_exception`` or a cause-chain repr. +* **V9 transport guard default-on (RANK 6)** -- ``Council.__init__`` installs the + httpx/httpcore DEBUG guard by default; ``allow_transport_debug_logging=True`` + opts out. +* **V10 client close hygiene (RANK 8)** -- the pooled client closes cleanly with no + ``ResourceWarning`` (cheap close-wiring regression guard). +""" + +from __future__ import annotations + +import json +import logging +import traceback +import warnings + +import httpx +import pytest + +from conclave import Council, guard_transport_logging, transport +from conclave.adapters.anthropic import AnthropicAdapter +from conclave.adapters.gemini import GeminiAdapter +from conclave.adapters.openai_compat import OpenAICompatAdapter +from conclave.config import ConclaveConfig, CustomEndpoint, clear_config_cache +from conclave.models import ModelAnswer +from conclave.providers import call_model, call_model_stream + +# An obviously-fake, key-SHAPED secret. The ``sk-`` prefix makes it match +# redact()'s pattern path; ``FAKE`` makes it unmistakably synthetic to a human +# reader and to gitleaks (allowlisted). If this string ever appears in a cache +# file, a streamed event, a repr, or a result error, a leak has occurred. +PLANTED = "sk-FAKEconclaveLEAK0123456789abcdefSECRET" + + +@pytest.fixture(autouse=True) +def _reset_config_cache(): + """Isolate each test from the in-process config memo.""" + clear_config_cache() + yield + clear_config_cache() + + +@pytest.fixture +async def mock_stream_client(): + """Install a MockTransport-backed pooled client; restore the global after. + + Mirrors the async fixture in ``test_streaming.py`` so the real + ``call_model_stream`` -> ``transport.stream_sse`` -> adapter path runs against + a caller-supplied handler with no network. + """ + saved = transport._client + created: list[httpx.AsyncClient] = [] + + def use(handler): + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + created.append(client) + transport._client = client + return client + + yield use + + for client in created: + if not client.is_closed: + await client.aclose() + transport._client = saved + + +# --------------------------------------------------------------------------- # +# V1 -- cache write path: a planted secret never reaches the cache (HIGH) +# --------------------------------------------------------------------------- # + + +def _cache_config(cache: bool = True) -> ConclaveConfig: + """A deterministic, on-disk-independent config for cache tests.""" + return ConclaveConfig( + models={"grok": "xai/grok-4.3", "claude": "anthropic/claude-sonnet-4-6"}, + councils={"default": ["grok", "claude"]}, + synthesizer="claude", + cache=cache, + ) + + +async def test_cache_never_persists_planted_secret_from_provider_error(monkeypatch, tmp_path): + """V1: a key echoed in a provider error must not land in any cache artifact. + + End-to-end: run a cached council where every member's mocked transport returns + a 401 whose error body echoes the planted key. The member errors are captured + via redact() before they reach the CouncilResult, so the on-disk cache entry + (and its filename and the cache key) must contain neither the secret value nor + the env var name -- proving the cache write happens strictly post-redaction. + """ + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + # The planted secret IS the live key value, so redact() can mask it by value + # even though one member's error also echoes it inline. + for var in ("XAI_API_KEY", "ANTHROPIC_API_KEY"): + monkeypatch.setenv(var, PLANTED) + + async def echoing_401(url, headers, json_body, timeout): + # A gateway that echoes the submitted credential back on auth failure. + return 401, {"error": {"message": f"invalid api key: {PLANTED}"}} + + monkeypatch.setattr("conclave.transport.post_json", echoing_401) + + council = Council( + models=["grok", "claude"], synthesizer="claude", config=_cache_config(), cache=True + ) + result = await council.ask("audit prompt", synthesize=True) + + # Every member failed -> each error must already be redacted on the result. + assert result.answers, "expected attempted members" + for ans in result.answers: + assert ans.error is not None + assert PLANTED not in ans.error + assert "[REDACTED]" in ans.error + + cache_home = tmp_path / "conclave" + entries = list(cache_home.glob("*.json")) + assert entries, "a cached entry should have been written" + for entry in entries: + blob = entry.read_text(encoding="utf-8") + assert PLANTED not in blob, "planted secret leaked into a cache file" + assert "XAI_API_KEY" not in blob + assert "ANTHROPIC_API_KEY" not in blob + # The filename (= cache key) must carry no secret either. + assert PLANTED not in entry.name + + # And the computed cache key itself is secret-free. + key = council._cache_key("audit prompt", "synthesize") + assert PLANTED not in key + + +async def test_cache_key_and_payload_have_no_env_value(monkeypatch, tmp_path): + """V1: even a SUCCESSFUL run never writes the key value or name to the cache.""" + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + for var in ("XAI_API_KEY", "ANTHROPIC_API_KEY"): + monkeypatch.setenv(var, PLANTED) + + async def ok_post(url, headers, json_body, timeout): + return 200, {"choices": [{"message": {"content": "benign answer"}}]} + + monkeypatch.setattr("conclave.transport.post_json", ok_post) + + council = Council(models=["grok"], synthesizer="claude", config=_cache_config(), cache=True) + await council.ask("benign prompt", synthesize=False) + + cache_home = tmp_path / "conclave" + for entry in cache_home.glob("*.json"): + blob = entry.read_text(encoding="utf-8") + assert PLANTED not in blob + assert "XAI_API_KEY" not in blob + + +# --------------------------------------------------------------------------- # +# V2 -- streaming chunk path: planted secret absent from stream AND final (MED) +# --------------------------------------------------------------------------- # + + +async def _collect_stream(name, model_id, **kwargs): + """Run call_model_stream, returning (text_chunks, final_ModelAnswer).""" + chunks: list[str] = [] + final: ModelAnswer | None = None + async for item in call_model_stream( + name, model_id, [{"role": "user", "content": "hi"}], **kwargs + ): + if isinstance(item, ModelAnswer): + final = item + else: + chunks.append(item) + return chunks, final + + +def _sse(*frames: str) -> bytes: + return ("".join(f"{frame}\n\n" for frame in frames)).encode("utf-8") + + +async def test_stream_planted_secret_absent_from_chunks_and_final(monkeypatch, mock_stream_client): + """V2: a mid-stream error echoing the key leaks into no chunk and no final. + + The provider streams a couple of good deltas, then returns a non-2xx whose + body echoes the planted key... but here we drive it via a 401 status so the + transport raises before any delta. We separately assert the partial-text case + below. This case proves: zero streamed chunks carry the secret, and the final + ModelAnswer.error is redacted. + """ + monkeypatch.setenv("OPENAI_API_KEY", PLANTED) + monkeypatch.setenv("CONCLAVE_CONFIG", "/nonexistent/conclave.yml") + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"error": {"message": f"bad key {PLANTED}"}}) + + mock_stream_client(handler) + chunks, final = await _collect_stream("openai", "openai/gpt-4.1") + + # No streamed chunk may carry the secret (error path yields no text deltas). + for chunk in chunks: + assert PLANTED not in chunk + assert final is not None and not final.ok + assert final.error is not None + assert PLANTED not in final.error + assert "[REDACTED]" in final.error + + +async def test_stream_midstream_error_after_partial_text_is_redacted( + monkeypatch, mock_stream_client +): + """V2: a structured error frame arriving mid-stream (after real deltas) leaks nothing. + + A provider can send good content deltas and THEN a structured error data frame + that echoes the key. The good deltas must stream (they are answer content), the + error must be captured + redacted on the final answer, the partial text must be + preserved, and the planted key must appear in NO streamed event NOR the final. + """ + monkeypatch.setenv("OPENAI_API_KEY", PLANTED) + monkeypatch.setenv("CONCLAVE_CONFIG", "/nonexistent/conclave.yml") + + def handler(request: httpx.Request) -> httpx.Response: + body = _sse( + 'data: {"choices":[{"delta":{"content":"partial "}}]}', + # A structured error frame mid-stream that echoes the credential. + 'data: {"error":{"message":"auth rejected: ' + PLANTED + '"}}', + "data: [DONE]", + ) + return httpx.Response(200, content=body) + + mock_stream_client(handler) + chunks, final = await _collect_stream("openai", "openai/gpt-4.1") + + # The good content streamed; no chunk carries the secret. + assert chunks == ["partial "] + for chunk in chunks: + assert PLANTED not in chunk + assert final is not None and not final.ok + assert final.error is not None + assert PLANTED not in final.error + assert "[REDACTED]" in final.error + # Partial text preserved AND clean. + assert (final.answer or "") == "partial " + assert PLANTED not in (final.answer or "") + + +async def test_council_stream_events_carry_no_planted_secret(monkeypatch, mock_stream_client): + """V2 at the council level: no StreamEvent in a full run carries the secret.""" + monkeypatch.setenv("OPENAI_API_KEY", PLANTED) + monkeypatch.setenv("CONCLAVE_CONFIG", "/nonexistent/conclave.yml") + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"error": {"message": f"bad key {PLANTED}"}}) + + mock_stream_client(handler) + + config = ConclaveConfig( + models={"openai": "openai/gpt-4.1"}, + councils={"default": ["openai"]}, + synthesizer="openai", + ) + council = Council(models=["openai"], synthesizer="openai", config=config) + + events = [e async for e in council.ask_stream("hi", synthesize=False)] + # Serialize every event the way a consumer would and scan the whole payload. + for ev in events: + dumped = json.dumps(ev.model_dump(mode="json")) + assert PLANTED not in dumped, f"planted secret leaked into a {ev.type} event" + + +# --------------------------------------------------------------------------- # +# V3 -- __repr__/__str__ never render key material (MED) +# --------------------------------------------------------------------------- # + + +def test_adapters_hold_no_key_and_repr_is_clean(): + """V3: adapters never store a key; building a request does not retain it. + + The key VALUE is passed to ``build_request`` per call and used only to compose + the (transient) headers dict the caller hands to the transport. It is never + assigned to ``self``. So an adapter instance's repr -- the thing that would + surface in a traceback frame referencing ``self`` -- cannot contain a key. + """ + adapters = [ + OpenAICompatAdapter( + prefix="openai", + completions_url="https://api.openai.com/v1/chat/completions", + env_vars=("OPENAI_API_KEY",), + ), + AnthropicAdapter(), + GeminiAdapter(), + ] + messages = [{"role": "user", "content": "hi"}] + for adapter in adapters: + url, headers, _body = adapter.build_request( + f"{adapter.prefix}/some-model", messages, 0.7, 30.0, PLANTED + ) + # The headers the transport receives DO carry the key (they must, to auth) + # -- that is the in-flight request, redaction-exempt by design and handled + # by the transport-logging guard (V5), not by repr. + header_blob = json.dumps(headers) + assert PLANTED in header_blob, "sanity: the live request must carry the key" + # But the adapter object itself retains nothing. + assert PLANTED not in repr(adapter) + assert PLANTED not in str(adapter) + assert PLANTED not in str(adapter.__dict__) + + +def test_config_repr_has_no_key_value(monkeypatch): + """V3: ConclaveConfig holds only env var NAMES, never values; repr is clean.""" + monkeypatch.setenv("TOGETHER_API_KEY", PLANTED) + config = ConclaveConfig( + models={"x": "together/m"}, + endpoints={ + "together": CustomEndpoint( + completions_url="https://api.together.xyz/v1/chat/completions", + env_var="TOGETHER_API_KEY", # NAME only + ) + }, + ) + assert PLANTED not in repr(config) + assert PLANTED not in str(config) + # The name is fine to render; the value must never be present. + assert "TOGETHER_API_KEY" in repr(config) + + +async def test_model_answer_repr_clean_after_provider_error(monkeypatch): + """V3 (async): the redacted ModelAnswer's repr/str/json carry no secret.""" + monkeypatch.setenv("OPENAI_API_KEY", PLANTED) + monkeypatch.setenv("CONCLAVE_CONFIG", "/nonexistent/conclave.yml") + + async def echoing_401(url, headers, json_body, timeout): + return 401, {"error": {"message": f"invalid api key: {PLANTED}"}} + + monkeypatch.setattr("conclave.transport.post_json", echoing_401) + + answer = await call_model("openai", "openai/gpt-4.1", [{"role": "user", "content": "hi"}]) + assert not answer.ok + assert PLANTED not in repr(answer) + assert PLANTED not in str(answer) + assert PLANTED not in json.dumps(answer.model_dump(mode="json")) + + +# --------------------------------------------------------------------------- # +# V4 -- provider 400/422 echo is captured AFTER redact() (MED) +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("status", [400, 401, 422, 500]) +async def test_buffered_error_echoing_key_is_redacted(monkeypatch, status): + """V4: a buffered HTTP error whose body echoes the key is scrubbed in .error.""" + monkeypatch.setenv("OPENAI_API_KEY", PLANTED) + monkeypatch.setenv("CONCLAVE_CONFIG", "/nonexistent/conclave.yml") + + async def echoing_error(url, headers, json_body, timeout): + # Provider echoes the submitted Authorization header back in its error. + return status, { + "error": {"message": f"request failed; headers: Authorization: Bearer {PLANTED}"} + } + + monkeypatch.setattr("conclave.transport.post_json", echoing_error) + + answer = await call_model("openai", "openai/gpt-4.1", [{"role": "user", "content": "hi"}]) + assert not answer.ok + assert answer.error is not None + assert PLANTED not in answer.error + assert "[REDACTED]" in answer.error + assert str(status) in answer.error + + +async def test_custom_endpoint_unprefixed_key_echo_is_redacted(monkeypatch, tmp_path): + """V4: an UNPREFIXED custom-endpoint key (no sk-/AIza shape) echoed in a 400 is scrubbed. + + Only name-based scrubbing (sourced from config.endpoints[*].env_var) can catch + a key with no recognizable shape -- this guards the BYO-keys leak class for + user-declared providers. + """ + unshaped = "togetherFAKEsecret_unprefixed_no_known_shape_0123456789" + monkeypatch.setenv("TOGETHER_API_KEY", unshaped) + + config_file = tmp_path / "conclave.yml" + config_file.write_text( + "endpoints:\n" + " together:\n" + " completions_url: https://api.together.xyz/v1/chat/completions\n" + " env_var: TOGETHER_API_KEY\n", + encoding="utf-8", + ) + monkeypatch.setenv("CONCLAVE_CONFIG", str(config_file)) + + async def echoing_400(url, headers, json_body, timeout): + return 400, {"error": {"message": f"bad request, key was {unshaped}"}} + + monkeypatch.setattr("conclave.transport.post_json", echoing_400) + + answer = await call_model( + "together", "together/some-model", [{"role": "user", "content": "hi"}] + ) + assert not answer.ok + assert answer.error is not None + assert unshaped not in answer.error + assert "[REDACTED]" in answer.error + + +# --------------------------------------------------------------------------- # +# V5 -- transport debug-logging guard blocks header-bearing DEBUG records (HIGH) +# --------------------------------------------------------------------------- # + + +def test_guard_transport_logging_blocks_debug_records(monkeypatch): + """V5: after guard install, httpx/httpcore DEBUG records are dropped, INFO+ kept. + + httpcore logs request headers (incl. Authorization) only at DEBUG. The guard + installs a filter that discards DEBUG records on the httpx/httpcore loggers, so + a header value can never reach a handler -- while INFO+ diagnostics survive. + """ + # Reset the one-shot guard flag so this test exercises a fresh install, then + # restore it so we don't perturb global state for other tests. + saved_flag = transport._GUARD_INSTALLED + httpx_logger = logging.getLogger("httpx") + httpcore_logger = logging.getLogger("httpcore") + saved_httpx_filters = httpx_logger.filters[:] + saved_httpcore_filters = httpcore_logger.filters[:] + monkeypatch.setattr(transport, "_GUARD_INSTALLED", False) + try: + guard_transport_logging() + + # A DEBUG record carrying a (fake) auth header must be filtered out. + debug_rec = httpcore_logger.makeRecord( + "httpcore", + logging.DEBUG, + __file__, + 0, + "send_request_headers.complete return_value=[(b'Authorization', b'Bearer %s')]", + (PLANTED.encode(),), + None, + ) + assert all(f.filter(debug_rec) is False for f in httpcore_logger.filters), ( + "DEBUG httpcore record (header-bearing) must be dropped by the guard" + ) + + # An INFO record on the same logger must survive (guard is DEBUG-only). + info_rec = httpcore_logger.makeRecord( + "httpcore", logging.INFO, __file__, 0, "connection established", (), None + ) + guard_filter = next( + f for f in httpcore_logger.filters if f.__class__.__name__ == "_NoDebugHeadersFilter" + ) + assert guard_filter.filter(info_rec) is True + + # The httpx logger got the same guard. + assert any(f.__class__.__name__ == "_NoDebugHeadersFilter" for f in httpx_logger.filters) + + # Idempotent: a second install does not stack a second filter. + before = len(httpcore_logger.filters) + guard_transport_logging() + assert len(httpcore_logger.filters) == before + finally: + httpx_logger.filters = saved_httpx_filters + httpcore_logger.filters = saved_httpcore_filters + transport._GUARD_INSTALLED = saved_flag + + +def test_guard_transport_logging_is_exported(): + """V5: the guard is part of the public API so library consumers can call it.""" + import conclave + + assert hasattr(conclave, "guard_transport_logging") + assert "guard_transport_logging" in conclave.__all__ + + +# --------------------------------------------------------------------------- # +# V7 -- residual catch-all error construction is redacted (audit-found gap) +# --------------------------------------------------------------------------- # +# +# Not in the original attack map: the partial-failure catch-alls in +# Council.fan_out and streaming._drive_member build a ModelAnswer.error from a raw +# exception (`f"{type(exc).__name__}: {exc}"`). These only fire on an UNEXPECTED +# raise that escapes call_model / call_model_stream (which already redact), but the +# invariant "every surfaced error string is scrubbed" must hold even there. The +# fix wraps both in redact(); these tests pin it by forcing the underlying call to +# RAISE (not return a ModelAnswer) with the planted key in the message. + + +async def test_fan_out_catch_all_error_is_redacted(monkeypatch): + """V7 (buffered): an unexpected raise in fan_out yields a redacted error.""" + monkeypatch.setenv("XAI_API_KEY", PLANTED) + + import conclave.council as council_mod + + async def raising_call_model(name, model_id, messages, *, temperature=0.7, timeout=120.0): + # Simulate an unexpected escape carrying the key in its text. + raise RuntimeError(f"unexpected boom leaking {PLANTED}") + + monkeypatch.setattr(council_mod, "call_model", raising_call_model) + + config = ConclaveConfig( + models={"grok": "xai/grok-4.3"}, + councils={"default": ["grok"]}, + synthesizer="grok", + ) + council = Council(models=["grok"], synthesizer="grok", config=config) + result = await council.ask("hi", synthesize=False) + + assert result.answers and not result.answers[0].ok + err = result.answers[0].error + assert err is not None + assert PLANTED not in err + assert "[REDACTED]" in err + + +async def test_stream_drive_member_catch_all_error_is_redacted(monkeypatch): + """V7 (streaming): an unexpected raise in _drive_member yields a redacted error.""" + monkeypatch.setenv("XAI_API_KEY", PLANTED) + + import conclave.streaming as streaming_mod + + async def raising_stream( + name, model_id, messages, *, temperature=0.7, timeout=120.0, config=None + ): + # An unexpected raise (not a yielded error ModelAnswer) carrying the key. + raise RuntimeError(f"stream boom leaking {PLANTED}") + yield # pragma: no cover -- make this an async generator + + monkeypatch.setattr(streaming_mod, "call_model_stream", raising_stream) + + config = ConclaveConfig( + models={"grok": "xai/grok-4.3"}, + councils={"default": ["grok"]}, + synthesizer="grok", + ) + council = Council(models=["grok"], synthesizer="grok", config=config) + + events = [e async for e in council.ask_stream("hi", synthesize=False)] + done = events[-1] + assert done.type == "done" + answers = done.result.answers + assert answers and not answers[0].ok + err = answers[0].error + assert err is not None + assert PLANTED not in err + assert "[REDACTED]" in err + # No StreamEvent anywhere carries the secret. + for ev in events: + assert PLANTED not in json.dumps(ev.model_dump(mode="json")) + + +# --------------------------------------------------------------------------- # +# V6 -- fixtures sanity: the planted secret is obviously fake +# --------------------------------------------------------------------------- # + + +def test_planted_secret_is_obviously_fake(): + """V6: the audit's planted secret is a synthetic, gitleaks-allowlisted token.""" + assert "FAKE" in PLANTED + assert PLANTED.startswith("sk-FAKE") + + +# --------------------------------------------------------------------------- # +# V8 -- transport drops the httpx exception from the cause chain (RANK 1/5, HIGH) +# --------------------------------------------------------------------------- # +# +# httpx exceptions carry a live ``.request`` whose ``.headers`` hold the +# ``Authorization``/``x-api-key`` value. If the surfaced TransportError kept that +# exception as ``__cause__``/``__context__``, the key would sit one cause-chain hop +# away and leak under ``traceback.format_exception``, ``logging.exception``, or a +# cause-chain repr. The transport raises ``... from None``; these tests pin that the +# formatted traceback + repr/str of the surfaced error are key-free and the cause +# chain is dropped. + + +def _planted_header_request(url: str = "https://x/y") -> httpx.Request: + """A real httpx.Request carrying the planted auth header (so .request.headers populates).""" + return httpx.Request("POST", url, headers={"Authorization": f"Bearer {PLANTED}"}) + + +def _assert_cause_chain_key_free(err: transport.TransportError) -> None: + """Shared V8 assertions: the surfaced TransportError leaks the key nowhere. + + The load-bearing guarantee is that ``traceback.format_exception`` (which honors + ``__suppress_context__``) renders neither the httpx exception nor its headers, + and that ``__cause__`` is dropped. The transport additionally clears + ``__context__`` at a boundary (see ``transport._raise_transport_error``), so a + *direct attribute walk* also finds no header-bearing httpx exception -- we pin + that too. + """ + formatted = "".join(traceback.format_exception(type(err), err, err.__traceback__)) + assert PLANTED not in formatted, "planted key leaked via the formatted traceback" + assert PLANTED not in repr(err) + assert PLANTED not in str(err) + assert err.__cause__ is None + assert err.__suppress_context__ is True + # Explicit context-clear makes even a direct ``err.__context__`` walk key-free. + assert err.__context__ is None + + +async def test_transport_error_drops_httpx_cause_chain(monkeypatch, mock_stream_client): + """V8 (buffered): post_json's TransportError keeps no header-bearing httpx cause. + + The MockTransport handler raises ``httpx.ConnectError`` carrying a request whose + headers hold the planted Authorization token. ``post_json`` must raise a + ``TransportError`` whose formatted traceback, repr, and str are all key-free and + whose cause chain is dropped (audit RANK 1/5). + """ + # Value-based redact would also mask the key in any *string* the transport built; + # the POINT here is the cause chain, asserted on the formatted traceback. + monkeypatch.setenv("OPENAI_API_KEY", PLANTED) + monkeypatch.setenv("XAI_API_KEY", PLANTED) + + def handler(request: httpx.Request) -> httpx.Response: + # Raise a network error carrying a header-bearing request (httpx.HTTPError arm). + raise httpx.ConnectError("boom", request=_planted_header_request()) + + mock_stream_client(handler) + + with pytest.raises(transport.TransportError) as excinfo: + await transport.post_json( + "https://x/y", + {"Authorization": f"Bearer {PLANTED}"}, + {"hi": "there"}, + timeout=5.0, + ) + _assert_cause_chain_key_free(excinfo.value) + + +async def test_stream_sse_error_drops_httpx_cause_chain(monkeypatch, mock_stream_client): + """V8 (streaming): stream_sse's TransportError keeps no header-bearing httpx cause. + + Same guarantee at the streaming boundary: the handler raises an httpx error + carrying the planted auth header; iterating the async generator surfaces a + ``TransportError`` whose formatted traceback / repr / str are key-free and whose + cause chain is dropped (audit RANK 1/5). + """ + monkeypatch.setenv("OPENAI_API_KEY", PLANTED) + monkeypatch.setenv("XAI_API_KEY", PLANTED) + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("boom", request=_planted_header_request()) + + mock_stream_client(handler) + + async def drain() -> None: + async for _event in transport.stream_sse( + "https://x/y", + {"Authorization": f"Bearer {PLANTED}"}, + {"hi": "there"}, + timeout=5.0, + ): + pass # pragma: no cover -- the handler raises before any event + + with pytest.raises(transport.TransportError) as excinfo: + await drain() + _assert_cause_chain_key_free(excinfo.value) + + +# --------------------------------------------------------------------------- # +# V9 -- Council installs the transport-logging guard by default (RANK 6) +# --------------------------------------------------------------------------- # + + +def _minimal_council_config() -> ConclaveConfig: + """A deterministic, on-disk-independent config for the guard-install tests.""" + return ConclaveConfig( + models={"openai": "openai/gpt-4.1"}, + councils={"default": ["openai"]}, + synthesizer="openai", + ) + + +def _snapshot_transport_logger_state(): + """Snapshot the httpx/httpcore filter lists + the one-shot guard flag.""" + httpx_logger = logging.getLogger("httpx") + httpcore_logger = logging.getLogger("httpcore") + return ( + httpx_logger, + httpcore_logger, + httpx_logger.filters[:], + httpcore_logger.filters[:], + transport._GUARD_INSTALLED, + ) + + +def test_council_init_installs_transport_guard_by_default(monkeypatch): + """V9 (RANK 6): constructing a Council installs the httpx/httpcore DEBUG guard. + + With no ``allow_transport_debug_logging`` argument the guard is ON: after + construction ``_GUARD_INSTALLED`` is True, the httpcore logger carries a + ``_NoDebugHeadersFilter``, and a header-bearing DEBUG record is dropped. Global + logging state is snapshotted and restored (mirrors the V5 teardown discipline). + """ + httpx_logger, httpcore_logger, saved_httpx, saved_httpcore, saved_flag = ( + _snapshot_transport_logger_state() + ) + monkeypatch.setattr(transport, "_GUARD_INSTALLED", False) + try: + Council(models=["openai"], synthesizer="openai", config=_minimal_council_config()) + + assert transport._GUARD_INSTALLED is True + guard_filter = next( + f for f in httpcore_logger.filters if f.__class__.__name__ == "_NoDebugHeadersFilter" + ) + # A header-bearing DEBUG record on httpcore must be dropped by the guard. + debug_rec = httpcore_logger.makeRecord( + "httpcore", + logging.DEBUG, + __file__, + 0, + "send_request_headers.complete return_value=[(b'Authorization', b'Bearer %s')]", + (PLANTED.encode(),), + None, + ) + assert guard_filter.filter(debug_rec) is False + finally: + httpx_logger.filters = saved_httpx + httpcore_logger.filters = saved_httpcore + transport._GUARD_INSTALLED = saved_flag + + +def test_council_opt_out_skips_transport_guard(monkeypatch): + """V9 (RANK 6 opt-out): allow_transport_debug_logging=True skips guard install. + + Starting from ``_GUARD_INSTALLED = False``, constructing a Council with the + opt-out flag must NOT flip the flag and must NOT add a new + ``_NoDebugHeadersFilter`` to the httpcore logger. Global state is restored after. + """ + httpx_logger, httpcore_logger, saved_httpx, saved_httpcore, saved_flag = ( + _snapshot_transport_logger_state() + ) + monkeypatch.setattr(transport, "_GUARD_INSTALLED", False) + filters_before = httpcore_logger.filters[:] + try: + Council( + models=["openai"], + synthesizer="openai", + config=_minimal_council_config(), + allow_transport_debug_logging=True, + ) + + # This construction must not have installed the guard. + assert transport._GUARD_INSTALLED is False + assert httpcore_logger.filters == filters_before + finally: + httpx_logger.filters = saved_httpx + httpcore_logger.filters = saved_httpcore + transport._GUARD_INSTALLED = saved_flag + + +# --------------------------------------------------------------------------- # +# V10 -- pooled client closes without a ResourceWarning (RANK 8, cheap) +# --------------------------------------------------------------------------- # + + +async def test_pooled_client_closes_without_resource_warning(): + """V10 (RANK 8): creating then closing the pooled client emits no ResourceWarning. + + A cheap close-wiring regression guard. ``_get_client()`` lazily builds the + pooled client; ``aclose()`` must release it cleanly. We snapshot/restore the + global client so this does not clobber other tests, and turn ResourceWarning + into an error so a leaked client surfaces immediately. + """ + saved = transport._client + transport._client = None + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", ResourceWarning) + client = transport._get_client() + assert client is not None + await transport.aclose() + assert transport._client is None + finally: + transport._client = saved diff --git a/tests/test_logging.py b/tests/test_logging.py index bc413d9..f127711 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -19,6 +19,21 @@ from conclave.logging import get_logger +def _own_handlers(logger: logging.Logger) -> list[logging.Handler]: + """Return only the handlers conclave installs, excluding pytest's capture ones. + + ``get_logger`` installs exactly one plain ``logging.StreamHandler`` on the + ``conclave`` root. Because that logger sets ``propagate = False``, pytest's + log-capture machinery attaches its own handlers (``LogCaptureHandler``, a + *subclass* of ``StreamHandler``) directly to it during a run -- the count of + which varies by pytest version. Selecting by exact type (``type(h) is + StreamHandler``) counts conclave's handler alone and ignores any injected + capture handler, so the one-shot-configuration assertions stay precise and + robust across pytest versions (pytest 9.x attaches more than older lines did). + """ + return [h for h in logger.handlers if type(h) is logging.StreamHandler] + + @pytest.fixture def fresh_logging(monkeypatch): """Reset the one-shot logger config so a fresh get_logger() reconfigures. @@ -54,8 +69,9 @@ def test_default_level_is_warning_when_env_unset(fresh_logging, monkeypatch): assert logger.name == "conclave" assert logger.level == logging.WARNING assert logger.propagate is False - assert len(logger.handlers) == 1 - assert isinstance(logger.handlers[0], logging.StreamHandler) + own = _own_handlers(logger) + assert len(own) == 1 + assert isinstance(own[0], logging.StreamHandler) def test_env_var_sets_level_case_insensitively(fresh_logging, monkeypatch): @@ -85,7 +101,8 @@ def test_named_logger_is_child_of_root(fresh_logging, monkeypatch): assert child.name == "conclave.transport" assert child.parent is logging.getLogger("conclave") # Child has no handler of its own; it propagates to the configured root. - assert child.handlers == [] + # (pytest may attach a capture handler; exclude it -- conclave adds none.) + assert _own_handlers(child) == [] # Effective level is inherited from the root we just configured at INFO. assert child.getEffectiveLevel() == logging.INFO @@ -95,7 +112,7 @@ def test_configuration_happens_once(fresh_logging, monkeypatch): monkeypatch.setenv("CONCLAVE_LOG_LEVEL", "ERROR") first = get_logger() - assert len(first.handlers) == 1 + assert len(_own_handlers(first)) == 1 assert logging_mod._CONFIGURED is True # Changing the env now must have no effect -- the guard short-circuits. @@ -103,5 +120,5 @@ def test_configuration_happens_once(fresh_logging, monkeypatch): second = get_logger() assert second is first - assert len(second.handlers) == 1 # not duplicated + assert len(_own_handlers(second)) == 1 # not duplicated assert second.level == logging.ERROR # unchanged from first config diff --git a/tests/test_synthesizer.py b/tests/test_synthesizer.py new file mode 100644 index 0000000..b1c384c --- /dev/null +++ b/tests/test_synthesizer.py @@ -0,0 +1,424 @@ +"""Regression tests pinning the SYNTHESIZER behavior (readiness must-do #5). + +The synthesizer/judge path is the heart of conclave's "council" value prop, so +its contract is pinned here explicitly rather than left implicit across +``test_council``/``test_modes``: + +* **selection** -- which model synthesizes by default, and that the constructor + arg, config, and CLI ``--synthesizer`` all override it (a, b, c); +* **observable degradation** -- the synthesizer failing or being unkeyed is + signaled on the result (``synthesis_error`` / ``verdict_error``), never a + silent quiet-concat degrade (c, d); +* **prompt versioning** -- the synthesis prompt is a stable, versioned constant, + stamped onto every result, so a wording change is detectable downstream (e). + +All tests run offline via the ``patch_call_model`` fixture (mocking at the same +httpx-transport boundary the rest of the suite uses); the CLI override test +drives Typer's ``CliRunner`` with no network and no real keys. +""" + +from __future__ import annotations + +import json + +import pytest +from typer.testing import CliRunner + +from conclave import Council, cli +from conclave.config import ConclaveConfig +from conclave.council import _SYNTH_SYSTEM +from conclave.council import SYNTHESIS_PROMPT_VERSION as COUNCIL_VERSION +from conclave.models import CouncilResult +from conclave.prompts import ( + DEBATE_FINAL_SYSTEM, + JUDGE_SYSTEM, + SYNTHESIS_PROMPT_VERSION, +) +from conclave.registry import DEFAULT_SYNTHESIZER +from tests.conftest import make_response + +runner = CliRunner() + + +def _all_keys(monkeypatch) -> None: + """Set every provider key to a dummy non-empty value.""" + for var in ( + "XAI_API_KEY", + "GEMINI_API_KEY", + "ANTHROPIC_API_KEY", + "PERPLEXITY_API_KEY", + "OPENAI_API_KEY", + ): + monkeypatch.setenv(var, "dummy-key") + + +def _config(synthesizer: str = "claude") -> ConclaveConfig: + """A deterministic config independent of any on-disk ~/.conclave file.""" + return ConclaveConfig( + models={ + "grok": "xai/grok-4.3", + "gemini": "gemini/gemini-2.5-pro", + "claude": "anthropic/claude-sonnet-4-6", + "perplexity": "perplexity/sonar-pro", + "openai": "openai/gpt-4.1", + }, + councils={"default": ["grok", "gemini", "claude", "perplexity"]}, + synthesizer=synthesizer, + ) + + +def _system_text(messages) -> str: + """Return the system-role content of a message list, or '' if none.""" + for m in messages: + if m.get("role") == "system": + return m.get("content", "") + return "" + + +# --------------------------------------------------------------------------- # +# (a) Default synthesizer selection +# --------------------------------------------------------------------------- # + + +def test_default_synthesizer_is_config_default(): + """No synthesizer arg -> the config's synthesizer is used (here 'claude').""" + council = Council(models=["grok", "gemini"], config=_config()) + assert council.synthesizer == "claude" + + +def test_default_synthesizer_falls_back_to_registry_default(): + """A config with the built-in default synthesizer resolves to 'claude'. + + Pins the bottom of the precedence chain: with no constructor arg and a config + whose synthesizer is the registry default, the council synthesizes with the + documented built-in (``DEFAULT_SYNTHESIZER``). + """ + council = Council( + models=["grok"], + config=ConclaveConfig(models={"grok": "xai/grok-4.3"}), # synthesizer defaults + ) + assert DEFAULT_SYNTHESIZER == "claude" + assert council.synthesizer == DEFAULT_SYNTHESIZER + + +async def test_default_synthesizer_runs_and_is_recorded(monkeypatch, patch_call_model): + """The default synthesizer actually performs the merge and is named on the result.""" + _all_keys(monkeypatch) + + def handler(model, messages, **kwargs): + # The synthesizer is anthropic/claude with the 2-message system+merge prompt. + if model == "anthropic/claude-sonnet-4-6" and _system_text(messages) == _SYNTH_SYSTEM: + return make_response("DEFAULT MERGE") + return make_response(f"answer from {model}") + + patch_call_model(handler) + + council = Council(models=["grok", "gemini"], config=_config()) # no synthesizer arg + result = await council.ask("q") + + assert result.synthesizer == "claude" + assert result.synthesizer_model_id == "anthropic/claude-sonnet-4-6" + assert result.synthesis == "DEFAULT MERGE" + + +# --------------------------------------------------------------------------- # +# (b) Configurable synthesizer override -- constructor arg + config +# --------------------------------------------------------------------------- # + + +def test_constructor_arg_overrides_config_synthesizer(): + """The constructor ``synthesizer=`` wins over the config default.""" + council = Council(models=["grok"], synthesizer="openai", config=_config("claude")) + assert council.synthesizer == "openai" + + +def test_config_synthesizer_used_when_no_arg(): + """With no constructor arg the config's synthesizer is honored (not the registry default).""" + council = Council(models=["grok"], config=_config("perplexity")) + assert council.synthesizer == "perplexity" + + +async def test_overridden_synthesizer_performs_the_merge(monkeypatch, patch_call_model): + """An overridden synthesizer (openai) is the model that runs the merge.""" + _all_keys(monkeypatch) + + def handler(model, messages, **kwargs): + if model == "openai/gpt-4.1" and _system_text(messages) == _SYNTH_SYSTEM: + return make_response("OPENAI MERGE") + return make_response(f"answer from {model}") + + patch_call_model(handler) + + council = Council(models=["grok", "gemini"], synthesizer="openai", config=_config("claude")) + result = await council.ask("q") + + assert result.synthesizer == "openai" + assert result.synthesizer_model_id == "openai/gpt-4.1" + assert result.synthesis == "OPENAI MERGE" + + +# --------------------------------------------------------------------------- # +# (c) Configurable synthesizer override -- CLI ``--synthesizer`` +# --------------------------------------------------------------------------- # + + +def test_cli_synthesizer_flag_overrides(monkeypatch, patch_call_model): + """``--synthesizer openai`` makes openai the synthesizer end-to-end via the CLI.""" + monkeypatch.setattr(cli, "load_config", lambda: _config("claude")) + for var in ("XAI_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY"): + monkeypatch.setenv(var, "dummy-key") + # claude (the config default) intentionally has NO key: if the flag were + # ignored, synthesis would degrade to a no-key error instead of merging. + + def handler(model, messages, **kwargs): + if model == "openai/gpt-4.1" and _system_text(messages) == _SYNTH_SYSTEM: + return make_response("CLI OPENAI MERGE") + return make_response(f"answer from {model}") + + patch_call_model(handler) + + result = runner.invoke( + cli.app, + ["ask", "q", "--council", "grok,gemini", "--synthesizer", "openai", "--json"], + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["synthesizer"] == "openai" + assert payload["synthesizer_model_id"] == "openai/gpt-4.1" + assert payload["synthesis"] == "CLI OPENAI MERGE" + + +# --------------------------------------------------------------------------- # +# (d) Degraded / fallback path is SIGNALED, never silent -- synthesize mode +# --------------------------------------------------------------------------- # + + +async def test_synthesizer_unkeyed_is_signaled_not_silent( + monkeypatch, patch_call_model, clear_keys +): + """Synthesizer with no key -> synthesis is None AND synthesis_error explains it. + + The degraded path must be observable: a caller can tell synthesis did not run + (no quietly-concatenated output masquerading as a synthesis). Member answers + are preserved; the selected synthesizer identity is still recorded. + """ + monkeypatch.setenv("XAI_API_KEY", "dummy") # only grok has a key; claude (synth) does not + + def handler(model, messages, **kwargs): + return make_response(f"answer from {model}") + + patch_call_model(handler) + + council = Council(models=["grok"], synthesizer="claude", config=_config()) + result = await council.ask("q") + + # Happy-path member output is untouched... + assert len(result.successful_answers) == 1 + # ...but synthesis is explicitly NOT produced, and the reason is observable. + assert result.synthesis is None + assert result.synthesis_error is not None + assert "no API key" in result.synthesis_error + # The selected synthesizer is still recorded even though it could not run. + assert result.synthesizer == "claude" + assert result.synthesizer_model_id == "anthropic/claude-sonnet-4-6" + + +async def test_synthesizer_call_failure_is_signaled(monkeypatch, patch_call_model): + """Synthesizer keyed but the call errors -> synthesis None, error surfaced verbatim.""" + _all_keys(monkeypatch) + + def handler(model, messages, **kwargs): + if model == "anthropic/claude-sonnet-4-6" and _system_text(messages) == _SYNTH_SYSTEM: + raise RuntimeError("synthesizer 503 from provider") + return make_response(f"answer from {model}") + + patch_call_model(handler) + + council = Council(models=["grok", "gemini"], synthesizer="claude", config=_config()) + result = await council.ask("q") + + # Members succeeded; only the synthesis step failed, and it is signaled. + assert len(result.successful_answers) == 2 + assert result.synthesis is None + assert result.synthesis_error is not None + assert "synthesizer 503 from provider" in result.synthesis_error + + +async def test_no_usable_answers_is_signaled(monkeypatch, patch_call_model): + """Every member fails -> synthesis None with a 'nothing to merge' signal.""" + _all_keys(monkeypatch) + + def handler(model, messages, **kwargs): + raise RuntimeError("all members down") + + patch_call_model(handler) + + council = Council(models=["grok", "gemini"], synthesizer="claude", config=_config()) + result = await council.ask("q") + + assert result.synthesis is None + assert result.synthesis_error is not None + assert "no successful member answers" in result.synthesis_error + + +# --------------------------------------------------------------------------- # +# (d') Degraded path is SIGNALED -- adversarial JUDGE (the analogous role) +# --------------------------------------------------------------------------- # + + +async def test_adversarial_judge_unkeyed_is_signaled(monkeypatch, patch_call_model, clear_keys): + """Judge (synthesizer) with no key -> verdict None, verdict_error + mirror set. + + The adversarial judge is the same model as the synthesizer; its degraded path + must be just as observable. The proposal and critiques survive; the missing + verdict is signaled on both ``adversarial.verdict_error`` and the mirrored + ``result.synthesis_error``. + """ + monkeypatch.setenv("XAI_API_KEY", "dummy") + monkeypatch.setenv("GEMINI_API_KEY", "dummy") + # claude (the judge) intentionally has no key. + + def handler(model, messages, **kwargs): + if "critic on an adversarial review" in _system_text(messages): + return make_response(f"crit {model}") + return make_response(f"prop {model}") + + patch_call_model(handler) + + council = Council(models=["grok", "gemini"], synthesizer="claude", config=_config()) + result = await council.adversarial("q") + + adv = result.adversarial + assert adv is not None + assert adv.proposal.ok # proposal survived + assert len(adv.successful_critiques) == 1 # a critique survived + assert adv.verdict is None + assert adv.verdict_error is not None + assert "no API key" in adv.verdict_error + # The selected judge identity is recorded, and the error mirrors to synthesis_error. + assert adv.judge == "claude" + assert adv.judge_model_id == "anthropic/claude-sonnet-4-6" + assert result.synthesis_error == adv.verdict_error + + +async def test_adversarial_judge_call_failure_is_signaled(monkeypatch, patch_call_model): + """Judge keyed but the verdict call errors -> verdict None, error surfaced.""" + _all_keys(monkeypatch) + + def handler(model, messages, **kwargs): + system = _system_text(messages) + if "judge of an adversarial review" in system: + raise RuntimeError("judge 500 from provider") + if "critic on an adversarial review" in system: + return make_response(f"crit {model}") + return make_response(f"prop {model}") + + patch_call_model(handler) + + council = Council(models=["grok", "gemini"], synthesizer="claude", config=_config()) + result = await council.adversarial("q") + + adv = result.adversarial + assert adv is not None + assert adv.verdict is None + assert adv.verdict_error is not None + assert "judge 500 from provider" in adv.verdict_error + + +async def test_debate_synthesizer_unkeyed_is_signaled(monkeypatch, patch_call_model, clear_keys): + """Debate's final synthesizer with no key -> synthesis None + observable error.""" + monkeypatch.setenv("XAI_API_KEY", "dummy") # only grok; claude (synth) has no key + + def handler(model, messages, **kwargs): + return make_response(f"answer {model}") + + patch_call_model(handler) + + council = Council(models=["grok"], synthesizer="claude", config=_config()) + result = await council.debate("q", rounds=1) + + assert result.synthesis is None + assert result.synthesis_error is not None + assert "no API key" in result.synthesis_error + + +# --------------------------------------------------------------------------- # +# (e) Prompt-version constant is stable + asserted +# --------------------------------------------------------------------------- # + + +def test_prompt_version_is_a_stable_nonempty_string(): + """The version tag is a non-empty string and re-exported consistently.""" + assert isinstance(SYNTHESIS_PROMPT_VERSION, str) + assert SYNTHESIS_PROMPT_VERSION + # council re-exports the same object the prompts module owns. + assert COUNCIL_VERSION == SYNTHESIS_PROMPT_VERSION + + +def test_prompt_version_is_pinned(): + """Pin the exact version so a prompt change without a version bump fails CI. + + This is the tripwire: editing any synthesizer-facing prompt below WITHOUT + bumping ``SYNTHESIS_PROMPT_VERSION`` leaves this assertion (and the prompt-text + pins) inconsistent, so the change cannot land silently. + """ + assert SYNTHESIS_PROMPT_VERSION == "2026-06-14" + + +def test_synthesis_prompt_text_is_pinned(): + """Pin the synthesize/debate/judge prompt wording. + + Guards the happy-path output contract: the synthesizer-facing prompts are + byte-stable. Any intentional edit must update this test AND bump + ``SYNTHESIS_PROMPT_VERSION`` (see ``test_prompt_version_is_pinned``). + """ + assert _SYNTH_SYSTEM.startswith("You are the synthesizer of a council of AI models.") + assert "rely only on the answers provided" in _SYNTH_SYSTEM + assert DEBATE_FINAL_SYSTEM.startswith("You are the synthesizer concluding") + assert JUDGE_SYSTEM.startswith("You are the judge of an adversarial review.") + + +def test_every_result_carries_the_prompt_version(): + """A bare CouncilResult defaults prompt_version to the current tag.""" + result = CouncilResult(prompt="x") + assert result.prompt_version == SYNTHESIS_PROMPT_VERSION + + +async def test_live_run_stamps_prompt_version(monkeypatch, patch_call_model): + """A real synthesize run stamps the version onto the result (and into JSON).""" + _all_keys(monkeypatch) + + def handler(model, messages, **kwargs): + if model == "anthropic/claude-sonnet-4-6" and _system_text(messages) == _SYNTH_SYSTEM: + return make_response("MERGE") + return make_response(f"answer from {model}") + + patch_call_model(handler) + + council = Council(models=["grok", "gemini"], synthesizer="claude", config=_config()) + result = await council.ask("q") + + assert result.prompt_version == SYNTHESIS_PROMPT_VERSION + # The version survives JSON serialization for downstream eval pipelines. + assert result.model_dump(mode="json")["prompt_version"] == SYNTHESIS_PROMPT_VERSION + + +@pytest.mark.parametrize("mode", ["raw", "debate", "adversarial"]) +def test_prompt_version_stamped_in_every_mode(monkeypatch, patch_call_model, mode): + """Every mode's result carries prompt_version, even when synthesis does not run.""" + _all_keys(monkeypatch) + + def handler(model, messages, **kwargs): + return make_response(f"answer {model}") + + patch_call_model(handler) + + council = Council(models=["grok", "gemini"], synthesizer="claude", config=_config()) + if mode == "raw": + result = council.ask_sync("q", synthesize=False) + elif mode == "debate": + result = council.debate_sync("q", rounds=1) + else: + result = council.adversarial_sync("q") + + assert result.prompt_version == SYNTHESIS_PROMPT_VERSION