diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6da64f9..ac62199 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,10 @@ # ============================================================================= # Release & publish workflow (v1.0 — W1.1 / W1.2 / W2.3) # -# !!! 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 `mcp-warden-cli` PyPI project + its Trusted Publisher (OIDC) 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). +# A published GitHub Release builds and signs artifacts. Production uploads +# additionally require the existing PyPI Trusted Publisher and enabled repo gate. +# A pushed tag alone does not trigger publication. Manual choices are TestPyPI, +# build-only, or a production authentication-only check that uploads nothing. # # What it does WHEN a Release is published: # build — builds sdist + wheel, uploads them as workflow artifacts. @@ -41,18 +34,23 @@ on: release: types: [published] - # Manual escape hatch for a dry run to TestPyPI (no Release required, no signing, - # no production PyPI). Nice-to-have; kept deliberately simple. + # Manual TestPyPI/build-only paths and an authentication-only production check. workflow_dispatch: inputs: publish-target: - description: "Where to publish on a manual run" + description: "TestPyPI upload, build-only, or production OIDC verification (no upload)" required: true default: "testpypi" type: choice options: - testpypi - none + - verify-pypi + publisher-checked: + description: "verify-pypi only: inspected existing project publisher and no matching pending publisher in owner account" + type: boolean + required: true + default: false # Least-privilege at the top level; each job widens scope locally only as needed. permissions: @@ -65,6 +63,7 @@ jobs: # -------------------------------------------------------------------------- build: name: Build sdist + wheel + if: github.event_name != 'workflow_dispatch' || github.event.inputs.publish-target != 'verify-pypi' runs-on: ubuntu-latest permissions: contents: read @@ -138,7 +137,7 @@ jobs: # # On a `release: published` run -> production PyPI. # On a manual `workflow_dispatch` with publish-target=testpypi -> TestPyPI only. - # publish-target=none short-circuits (build + artifacts only). + # publish-target=none builds only; verify-pypi skips the build and upload jobs. # -------------------------------------------------------------------------- pypi-publish: name: Publish to PyPI (OIDC Trusted Publishing) @@ -159,7 +158,7 @@ jobs: if: >- vars.PYPI_TRUSTED_PUBLISHER == 'true' && (github.event_name == 'release' || - (github.event_name == 'workflow_dispatch' && github.event.inputs.publish-target != 'none')) + (github.event_name == 'workflow_dispatch' && github.event.inputs.publish-target == 'testpypi')) steps: - name: Download dist artifacts @@ -190,6 +189,32 @@ jobs: # Re-running a dry run no-ops on an already-uploaded TestPyPI version. skip-existing: true + # Authentication-only production check: no build, upload, signing or stored token. + # Uses this SAME workflow identity and no deployment environment, like publication. + pypi-verify: + name: Verify PyPI OIDC (no upload) + if: github.event_name == 'workflow_dispatch' && github.event.inputs.publish-target == 'verify-pypi' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + env: + WARDEN_PYPI_PUBLISHER_CONFIRMED: ${{ github.event.inputs.publisher-checked }} + steps: + - name: Require reviewed main + if: github.ref != 'refs/heads/main' + run: | + echo '::error::Production verification requires reviewed main.' + exit 1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - name: Verify production OIDC exchange without uploading + run: python scripts/verify_pypi_oidc.py + # -------------------------------------------------------------------------- # Job 3: sign the release artifacts with Sigstore keyless ("heal thyself", W2.3). # diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9b870..8f159d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,14 @@ Streamable HTTP; the v0.3 `guard` proxy adds deterministic runtime *result* insp ## [Unreleased] +### Release engineering + +- Add a manual `verify-pypi` production OIDC exchange check in the existing release + workflow, without building, signing, uploading or storing credentials. Identity + mismatches, redirects, failed exchanges and malformed responses fail closed. +- Correct release documentation: existing-project publisher setup, non-reserving + pending publishers, 2.0.0's manual recovery, and unchanged-artifact failed-job reruns. + ## [2.0.0] — 2026-10-02 ### Breaking compatibility diff --git a/DOCUMENTATION_INDEX.md b/DOCUMENTATION_INDEX.md index c662aa6..ebb470e 100644 --- a/DOCUMENTATION_INDEX.md +++ b/DOCUMENTATION_INDEX.md @@ -201,9 +201,11 @@ scope-honesty box and makes no compliance/regulatory claim. | Doc | Purpose | |-----|---------| -| [`RELEASING.md`](RELEASING.md) | Operator runbook: one-time PyPI Trusted-Publisher (OIDC) setup, cut-a-release checklist, post-release verification, rollback/yank. PyPI dist name is `mcp-warden-cli`; CLI/repo stay `mcp-warden`. | +| [`RELEASING.md`](RELEASING.md) | Operator runbook: existing-project PyPI publisher settings, authentication-only verification, release delivery, post-release checks, failed-job recovery and rollback/yank. PyPI dist name is `mcp-warden-cli`; CLI/repo stay `mcp-warden`. | | [`CHANGELOG.md`](CHANGELOG.md) | Keep-a-Changelog history through CLI 2.0.0 / schema level 4 with explicit in/out-of-scope. | -| [`.github/workflows/release.yml`](.github/workflows/release.yml) | Publish-on-Release workflow: build Python distributions + tested TypeScript 0.2.0 tarball → publish Python to PyPI via OIDC Trusted Publishing (no stored token, **gated on repo var `PYPI_TRUSTED_PUBLISHER=true`** + `skip-existing`, #64) → Sigstore-keyless sign Python, TypeScript, and checksums and attach bundles to the Release. Live: `mcp-warden-cli` Trusted Publisher configured + the gate variable set. | +| [`.github/workflows/release.yml`](.github/workflows/release.yml) | Publish-on-Release: build Python + tested TypeScript tarball; upload only Python through OIDC (repo gate + `skip-existing`); Sigstore-sign artifacts/checksums. Manual `verify-pypi` exchanges credentials without build/upload/signing. The gate variable alone does not prove publisher alignment. | +| [`scripts/verify_pypi_oidc.py`](scripts/verify_pypi_oidc.py) | Standard-library OIDC probe: reviewed-main binding, owner inspection acknowledgment, bounded HTTPS, no redirects/token logs/storage/uploads. PyPI minting can mutate pending records; exchange does not prove project upload permission. | +| [`docs/plans/2026-10-02-pypi-automation-repair.md`](docs/plans/2026-10-02-pypi-automation-repair.md) | Bounded automation repair and session wrap plan, including the authenticated PyPI browser boundary. | | [`requirements-dev.lock`](requirements-dev.lock) · [`.github/workflows/deps-locked.yml`](.github/workflows/deps-locked.yml) | **(#59)** Hash-pinned dev/CI dependency lock + the "Hash-locked dev/CI install" check (verifies `--require-hashes` install + that the lock stays in sync with `pyproject.toml` without floating to latest, #65). Dependency-update policy lives in [`SECURITY.md`](SECURITY.md). | --- diff --git a/README.md b/README.md index b44583b..2990645 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ drifts.** `pin` and `check` support stdio and Streamable HTTP; `guard` is stdio- destructiveHint, removing a result schema, or widening its types now triggers drift; the existing runtime tools/list gate checks these fields against v4 locks too. Older locks stay readable, but require review and re-pinning for this coverage. -Hints are server claims, not proof of safety, and schemas do not certify content. +Hints are server claims, not proof of safety, and schemas do not certify content. Release +operators can check production OIDC without uploading via [`RELEASING.md`](RELEASING.md). -The proposed next direction is a protocol-neutral **Warden**: human-approved tool -versions and bounded actions, with untrusted inputs kept separate from authority. +The proposed next direction is a protocol-neutral **Warden**: human-approved tool versions and bounded actions, with untrusted inputs kept separate from authority. The [upgrade plan and checkpoint proposal](docs/plans/2026-10-02-tool-integrity-upgrade.md) maps prompts, retrieval, code execution, and serverless adapters to existing Agent Trust Kernel work. Those broader checkpoints are proposals, not shipped guarantees. diff --git a/RELEASING.md b/RELEASING.md index f338487..6088fda 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -19,19 +19,38 @@ does not collide. 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 -publishing only succeeds once the one-time PyPI Trusted Publisher below exists. +publishes production packages only on a published GitHub Release. Manual modes +are TestPyPI upload, build-only, and production authentication verification without +uploading. Production authentication needs a matching PyPI Trusted Publisher. --- -## 0. One-time PyPI setup (do this ONCE, before the first release) +## 0. Configure the production publisher -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. +The production project already exists. Its publisher is managed at +[PyPI → mcp-warden-cli → Publishing](https://pypi.org/manage/project/mcp-warden-cli/settings/publishing/) +by a logged-in project owner. An upload API token is not a publisher-admin browser session. -### Recommended path — "pending publisher" (zero prior upload required) +For the existing workflow, the GitHub publisher fields are: + +| Field | Expected value | +|-------|----------------| +| Owner | `DataScience-EngineeringExperts` | +| Repository name | `mcp-warden` | +| Workflow name | `release.yml` | +| Environment name | blank: the current publishing job has no deployment environment | + +Inspect the current publisher before changing it. An existing environment restriction +must be deliberately aligned on both sides, preserving any required reviewer gate. +A repository transfer can also change the immutable owner ID PyPI records; a matching +display name alone does not prove identity alignment. See +[PyPI troubleshooting](https://docs.pypi.org/trusted-publishers/troubleshooting/). + +The workflow uses **OIDC Trusted Publishing**, without a stored GitHub upload token. +The repo variable `PYPI_TRUSTED_PUBLISHER=true` permits an attempt; it does not prove +that a matching PyPI publisher exists. Verify the exchange before cutting a new release. + +### New projects only — pending publisher 1. Log in to as the account that will own `mcp-warden-cli`. 2. Go to **Account → Publishing** (). @@ -43,13 +62,14 @@ the very first upload is already OIDC-published. - **Environment name**: *(leave blank — the workflow does not use a GitHub deployment environment; if you later add one, set it here and add `environment:` to the `pypi-publish` job)* -4. Save. PyPI now holds the project name `mcp-warden-cli` and will create it on the - first successful OIDC upload from `release.yml`. +4. Save the pending configuration. A successful authorized OIDC flow can create + the new project if the name remains available; saving does not reserve it. -A "pending publisher" reserves the name and lets the FIRST release be OIDC-published -— no manual upload, no token ever. +A pending publisher does **not** reserve a name or create a project. It is not the +setup path for the already-existing `mcp-warden-cli` project. See +[PyPI pending publishers](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/). -### Alternative path — manual first upload, then configure +### New projects only — manual first upload, then configure If you would rather seed the project manually first: @@ -73,7 +93,7 @@ without the publish job failing red before the Trusted Publisher exists. GitHub Release builds the sdist + wheel, Sigstore-signs them, and attaches the bundles to the Release — but the `pypi-publish` job is **SKIPPED** (gray, not red) and nothing is uploaded to PyPI. Use this to cut signed GitHub Releases for - versions already published by token (e.g. `1.0.0`, `1.0.1`). + historical versions already published by token (e.g. `1.0.0`, `1.0.1`). - **After you have configured the Trusted Publisher above** (project `mcp-warden-cli`, owner `DataScience-EngineeringExperts`, repo `mcp-warden`, workflow `release.yml`), enable OIDC publishing for future releases by setting the @@ -84,8 +104,73 @@ without the publish job failing red before the Trusted Publisher exists. or in the GitHub UI: **Settings → Secrets and variables → Actions → Variables → New repository variable**, name `PYPI_TRUSTED_PUBLISHER`, value `true`. -Re-running a Release for an already-published version is also safe: the publish step -uses `skip-existing: true`, so a duplicate version no-ops instead of failing. +The publish action must authenticate **before** `skip-existing: true` can handle +duplicate files. A previously uploaded version does not bypass a broken publisher. + +### Verify production authentication without uploading + +Before dispatch, a logged-in project owner must inspect the **normal** publisher +on the existing project's page above and check +[Account → Publishing](https://pypi.org/manage/account/publishing/) for matching +pending publishers visible in that account. Confirm the intended owner/repository/ +workflow/environment and that no matching pending entry is visible. Do not dispatch +against a pending publisher or acknowledge an inspection that has not occurred. + +The exchange is **not universally read-only**: PyPI's mint endpoint checks pending +publishers first and can activate/create or remove their server-side records. The +owning-account check cannot exclude other users' pending records. The verification +path creates only an in-memory upload credential and never uploads a package; +it is not a server-wide non-mutation guarantee. + +After that inspection, dispatch the existing release workflow on reviewed `main`: + +```bash +gh workflow run release.yml --ref main \ + --field publish-target=verify-pypi --field publisher-checked=true +gh run list --workflow release.yml --event workflow_dispatch --limit 1 +gh run view --log +``` + +The check refuses other branches/tags and an unconfirmed publisher before requesting +any tokens. Only **Verify PyPI OIDC (no upload)** runs. Build, signing and upload jobs are +skipped; the script uses Python's standard library and preserves the release +workflow's repository/owner/workflow identity and current environment contract. +It validates the GitHub request host, refuses redirects, limits response sizes +and suppresses credentials and arbitrary server error text. The minted short-lived +credential remains only in memory and is discarded without being used. + +**Pass:** the exchange job succeeds and reports that PyPI accepted the workflow +identity. This confirms authentication, not project-specific upload permission or +package safety. Confirm the normal publisher is attached to `mcp-warden-cli` in +the project settings; a pending publisher is not the intended verification target. +**Fail:** any failed/malformed exchange, identity mismatch or missing permission +exits nonzero. A skipped publication job is not proof of working authentication. + +For `invalid-publisher`, compare the existing project's four fields above and the +immutable owner identity with the current workflow. Do not copy tokens into logs, +add a workflow token fallback, disable the gate to hide the failure, or remove a +reviewer/environment restriction to make the check pass. + +### 2.0.0 recovery and retry boundary + +[Release run 36960647544](https://github.com/DataScience-EngineeringExperts/mcp-warden/actions/runs/36960647544) +built and signed the release but failed PyPI's exchange with `invalid-publisher`. +The same exchange error occurred on the prior 1.2.0 release. The authorized manual +2.0.0 upload used the exact signed GitHub wheel/sdist; their SHA-256 hashes match +[PyPI 2.0.0](https://pypi.org/project/mcp-warden-cli/2.0.0/). That recovery proves +publication, not that OIDC automation was repaired. + +After the publisher is aligned and authentication succeeds, rerun only the failed +job from the original release run, using its retained build artifacts: + +```bash +gh run rerun 36960647544 --failed +``` + +Do not rebuild/re-sign/replace the released assets as a repair attempt. If the +original artifacts expired, stop and plan a fix-forward release. Success means +OIDC authentication passes and the existing immutable files are handled by +`skip-existing`; verify the public wheel/sdist hashes remain unchanged. ### (Optional) TestPyPI dry-run publisher @@ -98,46 +183,49 @@ needed if you want to rehearse the publish without touching production PyPI. ## 1. Cut a release -Do this on a clean checkout of `main` with all v1 PRs merged. +Do this on a clean checkout of `main` with the intended changes merged through a reviewed PR. +The commands below use `2.0.1` as an example next version, not a published release. 1. **Update the changelog.** In [`CHANGELOG.md`](CHANGELOG.md), move the - `## [Unreleased]` entries under a new `## [1.0.0] - ` heading with + `## [Unreleased]` entries under a new `## [2.0.1] - ` heading with today's date. Leave a fresh empty `## [Unreleased]` section above it. -2. **Bump the version.** In [`pyproject.toml`](pyproject.toml), set - `[project] version = "1.0.0"`. +2. **Bump both Python versions.** In [`pyproject.toml`](pyproject.toml), set + `[project] version = "2.0.1"`; update `__version__` in + [`src/mcp_warden/__init__.py`](src/mcp_warden/__init__.py) to match. 3. **Commit.** ```bash - git add CHANGELOG.md pyproject.toml - git commit -m "release: v1.0.0" - git push origin main + git add CHANGELOG.md pyproject.toml src/mcp_warden/__init__.py + git commit -m "release: v2.0.1" + # Push a release branch and merge its reviewed PR; do not push directly to 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.) +4. **Refresh the merged `main`, then tag and push the tag.** Required CI must be + green at the reviewed release commit. A tag alone does not publish packages; + the Release in the next step triggers the workflow. ```bash - git tag v1.0.0 - git push origin v1.0.0 + git tag v2.0.1 + git push origin v2.0.1 ``` 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) + gh release create v2.0.1 --verify-tag \ + --title "v2.0.1" \ + --notes-file /tmp/release-notes.md ``` - or use the GitHub UI: **Releases → Draft a new release → choose tag `v1.0.0` → + or use the GitHub UI: **Releases → Draft a new release → choose tag `v2.0.1` → Publish release**. Publishing the Release fires `release.yml`, which: - **build** — builds the sdist + wheel and uploads them as workflow artifacts; - **pypi-publish** — publishes those artifacts to PyPI via OIDC (no token). **Skipped unless** the repo variable `PYPI_TRUSTED_PUBLISHER` is `true` - (see "Enable OIDC publishing" in section 0). For versions already published - by token (`1.0.0`, `1.0.1`) leave it unset so this job skips cleanly; + (see "Enable OIDC publishing" in section 0). Previously published files still + require valid authentication; do not hide an exchange failure; - **sign** — signs the sdist + wheel with Sigstore keyless and attaches the - `.sigstore` bundle(s) to the Release assets (runs regardless of the gate). + `.sigstore.json` bundle(s) to the Release assets (runs regardless of the gate). --- @@ -156,30 +244,31 @@ the exact GitHub asset into a fresh consumer and import `@mcp-warden/lock`. 1. **Install from PyPI** (give the CDN a minute): ```bash - pip install mcp-warden-cli + pip install mcp-warden-cli==2.0.1 mcp-warden --version ``` - The version must print `1.0.0`. Note the install name is `mcp-warden-cli`, the + The version must print `2.0.1`. Note the install name is `mcp-warden-cli`, the command is `mcp-warden`. 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 + `.sigstore.json` (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/mcp_warden_cli-1.0.0-py3-none-any.whl \ - --bundle mcp_warden_cli-1.0.0-py3-none-any.whl.sigstore \ + sigstore verify identity dist/mcp_warden_cli-2.0.1-py3-none-any.whl \ + --bundle mcp_warden_cli-2.0.1-py3-none-any.whl.sigstore.json \ --cert-identity \ - "https://github.com/DataScience-EngineeringExperts/mcp-warden/.github/workflows/release.yml@refs/tags/v1.0.0" \ + "https://github.com/DataScience-EngineeringExperts/mcp-warden/.github/workflows/release.yml@refs/tags/v2.0.1" \ --cert-oidc-issuer "https://token.actions.githubusercontent.com" ``` - (Download the `.whl` and its `.sigstore` bundle from the Release assets first.) + (Download the `.whl` into `dist/` and its `.sigstore.json` bundle first.) 3. **Confirm the PyPI page.** Visit and check: - - version `1.0.0` is listed; + - version `2.0.1` is listed; - the project URLs (homepage / repository) point at `DataScience-EngineeringExperts/mcp-warden`; - - "Publisher" shows the Trusted Publisher (OIDC), not a token upload. + - an automated upload records Trusted Publisher provenance; the manual 2.0.0 + recovery must not be described as an OIDC upload. 4. **Smoke-test the gate** in a throwaway dir to confirm the published wheel works: ```bash @@ -196,12 +285,12 @@ 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`. +- **Ship a fix-forward release** (`2.0.2`) following section 1 again. This is the + preferred remedy — never try to re-upload `2.0.1`. - **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. + only with a deliberate repair plan; that does not change PyPI bytes. For publisher + repair, rerun only the failed upload job with the original artifacts. Normal + duplicate handling uses `skip-existing` after successful authentication. --- @@ -210,7 +299,7 @@ release is broken: - **No stored secret.** OIDC Trusted Publishing means GitHub never holds a PyPI token; PyPI trusts the workflow identity directly. Same trust model as the repo's existing keyless Sigstore signing. -- **Heal thyself.** mcp-warden signs everyone else's locks; from v1.0.0 it signs its +- **Heal thyself.** mcp-warden signs everyone else's locks; from v2.0.1 it signs its own release artifacts too (the `sign` job), so consumers can verify the wheel they install came from this repo's release workflow. - **Explicit gesture.** A pushed tag does nothing; only *publishing a Release* ships. diff --git a/SYSTEM_CONTEXT_DIAGRAM.md b/SYSTEM_CONTEXT_DIAGRAM.md index 4e35f41..faf0e70 100644 --- a/SYSTEM_CONTEXT_DIAGRAM.md +++ b/SYSTEM_CONTEXT_DIAGRAM.md @@ -84,6 +84,15 @@ logic) plus a separate informational provenance section. It never prints raw > the same way ("heal thyself"). Signing is the optional `mcp-warden-cli[sigstore]` extra — the > core gate has no crypto dependency. +> **Release authentication:** `release.yml` keeps production uploads behind the existing +> OIDC publisher and repo gate. Manual `verify-pypi` checks only the credential exchange; +> build, upload and signing jobs are skipped. The script logs neither identity nor upload +> tokens, requires reviewed `main` and an owner publisher-inspection acknowledgment. +> Minting can change PyPI pending-publisher records; it is not universally read-only. +> Successful exchange is not proof of project upload permission. CLI 2.0.0's +> failed OIDC exchange was recovered with an authorized manual upload of the signed bytes; +> publisher alignment must be verified independently. See [`RELEASING.md`](RELEASING.md). + --- ## C1 — System context diff --git a/docs/plans/2026-10-02-pypi-automation-repair.md b/docs/plans/2026-10-02-pypi-automation-repair.md new file mode 100644 index 0000000..b430509 --- /dev/null +++ b/docs/plans/2026-10-02-pypi-automation-repair.md @@ -0,0 +1,53 @@ +# PyPI automation repair implementation plan + +> **For Claude:** Execute the bounded tasks below in the existing repair worktree. + +**Goal:** Restore verified production Trusted Publishing where authenticated access permits, and give operators a safe authentication-only check without creating a release or uploading packages. + +**Architecture:** Preserve `release.yml`, its current GitHub identity, pinned actions, job permissions, and OIDC-only publication. Add a manually selected `verify-pypi` path that exchanges the workflow's short-lived identity without building, uploading, signing, or storing credentials. PyPI publisher administration remains an authenticated project-owner browser operation; do not substitute the existing upload token for that authority. + +**Tech Stack:** GitHub Actions, Python 3.11 standard library HTTPS/JSON, pytest, MkDocs. + +--- + +## Task 1: Establish the configuration boundary + +- Inspect the failed release's sanitized claim summary and GitHub environments. +- Read PyPI's official publisher documentation and management implementation. +- Inspect the production settings page when authenticated admin access is available; otherwise request its four publisher fields from the owner once. +- Evidence already obtained: the management page redirects this builder to login; GitHub has only `github-pages`; the release job declares no environment. Do not infer which PyPI-side field is wrong. + +## Task 2: Implement a non-uploading verification path + +**Files:** `.github/workflows/release.yml`, `scripts/verify_pypi_oidc.py`, `tests/test_release_oidc.py`. + +- Add `verify-pypi` to the existing manual choices; skip build and publish jobs for it. +- Run the standard-library script in a job with the existing `id-token: write` and `contents: read` permissions. Both job and script require reviewed `main`; the script requires an explicit owner publisher-inspection acknowledgment before requesting tokens. +- Validate the GitHub request endpoint, audience and current workflow/repository claims; reject redirects and unexpected claims before PyPI exchange. +- Send secrets only as HTTPS headers/bodies. Never log tokens, response bodies, or uncontrolled server error descriptions; retain only fixed failure codes and status. +- Require a valid, short-lived successful PyPI exchange response. Clearly label success as authentication, not proof of package/project upload authorization. +- Inspect a normal existing-project publisher and absence of matching pending publishers visible in the owning account before dispatch. PyPI minting checks pending publishers first and can mutate their records; the account check cannot establish universal absence. No live probe is authorized by a guessed inspection acknowledgment. +- Test both routes' separation, safe exchange, identity mismatch, malicious redirects, invalid/malformed responses, bounded network errors and log non-disclosure. + +## Task 3: Correct and sync release documentation + +**Files:** `RELEASING.md`, `README.md`, `SYSTEM_CONTEXT_DIAGRAM.md`, `DOCUMENTATION_INDEX.md`, `CHANGELOG.md`. + +- Lead existing-project instructions with the exact production project publishing URL and expected fields. +- Correct the false claim that a pending publisher reserves a project name. +- Record 2.0.0's verified manual recovery and the outstanding OIDC mismatch without claiming it is repaired. +- Document `verify-pypi`, its boundary and a failed-job-only rerun using the existing artifacts after publisher alignment. +- Re-read and validate all three core docs, their links and the 500-line cap. + +## Task 4: Evaluate, deliver and verify + +- Run focused probe/workflow tests, Ruff, strict docs build and rendered desktop/mobile QA for changed public docs. +- Obtain the required independent security review of the concrete head; deliver through a PR with required CI and the existing merge helper. +- Run the production authentication-only path. A red exchange remains a failure, never a skip/disabled gate presented as success. +- If the owner aligns the publisher, rerun only the old release's failed PyPI job; confirm authentication, duplicate handling and unchanged public hashes. + +## Task 5: Session wrap + +- Preserve release URLs, exact repair commit/PR, test results, actual publisher/probe status and the one remaining action, if any. +- Save a bounded Codex memory note and attempt the authorized Notion mirror; report the builder's iCloud discovery refresh as skipped. +- Do not close DSE-1539 while its pagination acceptance is incomplete or mutate another agent's started issue. diff --git a/scripts/verify_pypi_oidc.py b/scripts/verify_pypi_oidc.py new file mode 100644 index 0000000..61064ed --- /dev/null +++ b/scripts/verify_pypi_oidc.py @@ -0,0 +1,149 @@ +"""Check release.yml's production OIDC exchange without uploading or logging tokens.""" + +from __future__ import annotations + +import base64 +import json +import os +import time +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +REPOSITORY = "DataScience-EngineeringExperts/mcp-warden" +OWNER_ID = "115239380" +WORKFLOW = f"{REPOSITORY}/.github/workflows/release.yml" +REF = "refs/heads/main" +MAX_RESPONSE = 65_536 +ERROR_CODES = frozenset({ + "invalid-publisher", "invalid-pending-publisher", "invalid-token", "invalid-payload", + "invalid-reuse-token", "rate-limit-exceeded", +}) + + +class ProbeError(Exception): + """Messages are fixed locally; never include credentials or server descriptions.""" + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise ProbeError("Redirect refused; credentials were not forwarded.") + + +def _json_response(stream): + raw = stream.read(MAX_RESPONSE + 1) + if len(raw) > MAX_RESPONSE: + raise ProbeError("Response exceeded the verification size limit.") + try: + result = json.loads(raw) + except (ValueError, UnicodeError): + raise ProbeError("Response was not valid JSON.") from None + if not isinstance(result, dict): + raise ProbeError("Response was not a JSON object.") + return result + + +def fetch_json(request: Request, stage: str): + try: + with build_opener(NoRedirect()).open(request, timeout=30) as response: + return _json_response(response) + except HTTPError as exc: + # PyPI can include arbitrary descriptions; only print known error codes. + code = "unrecognized-error" + try: + body = _json_response(exc) + errors = body.get("errors") + if isinstance(errors, list): + for item in errors: + if isinstance(item, dict) and item.get("code") in ERROR_CODES: + code = item["code"] + break + except (ProbeError, TypeError): + pass + raise ProbeError(f"{stage}: HTTP {exc.code}, {code}.") from None + except (URLError, TimeoutError): + raise ProbeError(f"{stage}: HTTPS connection failed.") from None + + +def github_request_url(raw: str) -> str: + parsed = urlsplit(raw) + if (parsed.scheme != "https" or not parsed.hostname + or not parsed.hostname.endswith(".actions.githubusercontent.com") + or parsed.username is not None or parsed.password is not None + or parsed.port not in (None, 443) or parsed.fragment): + raise ProbeError("GitHub OIDC request endpoint was not an approved HTTPS host.") + query = [(k, v) for k, v in parse_qsl(parsed.query) if k != "audience"] + return urlunsplit(parsed._replace(query=urlencode([*query, ("audience", "pypi")]))) + + +def check_claims(token: str) -> None: + # Decoding is a preflight check, NOT signature verification. PyPI verifies the JWT. + try: + parts = token.split(".") + if len(parts) != 3: + raise ValueError + payload = parts[1] + claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) + if not isinstance(claims, dict): + raise ValueError + except (ValueError, UnicodeError): + raise ProbeError("GitHub did not return a parseable OIDC identity.") from None + expected = { + "iss": "https://token.actions.githubusercontent.com", "aud": "pypi", + "repository": REPOSITORY, "repository_owner": REPOSITORY.split("/")[0], + "repository_owner_id": OWNER_ID, "workflow_ref": f"{WORKFLOW}@{REF}", + "sub": f"repo:{REPOSITORY}:ref:{REF}", + } + if any(claims.get(key) != value for key, value in expected.items()): + raise ProbeError("GitHub OIDC claims did not match this repository's release workflow.") + if "environment" in claims or claims.get("job_workflow_ref", expected["workflow_ref"]) != expected["workflow_ref"]: + raise ProbeError("Unexpected environment or reusable workflow identity.") + + +def verify() -> None: + if os.environ.get("WARDEN_PYPI_PUBLISHER_CONFIRMED") != "true": + raise ProbeError("Inspect the existing normal publisher and account pending publishers before confirming this check.") + required = ("ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN", "GITHUB_REF") + if any(not os.environ.get(key) for key in required): + raise ProbeError("Run the verify-pypi job in GitHub Actions with id-token: write.") + if os.environ["GITHUB_REF"] != REF: + raise ProbeError("Verification requires the reviewed main branch.") + url = github_request_url(os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"]) + github = fetch_json(Request(url, headers={ + "Authorization": "Bearer " + os.environ["ACTIONS_ID_TOKEN_REQUEST_TOKEN"], + }), "GitHub identity request") + token = github.get("value") + if not isinstance(token, str) or not token: + raise ProbeError("GitHub did not return an OIDC identity.") + check_claims(token) + result = fetch_json(Request( + "https://pypi.org/_/oidc/mint-token", + data=json.dumps({"token": token}).encode(), + headers={"Content-Type": "application/json"}, method="POST", + ), "PyPI exchange") + credential, expiry = result.get("token"), result.get("expires") + now = time.time() + if (result.get("success") is not True or not isinstance(credential, str) + or not credential.startswith("pypi-") or type(expiry) is not int + or not now < expiry <= now + 930): + raise ProbeError("PyPI did not return a successful short-lived credential exchange.") + # The upload credential exists only in memory and is never used or stored. + print("PyPI accepted this release workflow's OIDC identity.") + print("No packages uploaded. Project upload authorization remains untested.") + + +def main() -> int: + try: + verify() + except ProbeError as exc: + print(f"::error::{exc}") + return 1 + except Exception: + # Do not render unexpected exceptions: they may include headers or tokens. + print("::error::Unexpected verification failure; response details withheld.") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_oidc.py b/tests/test_release_oidc.py new file mode 100644 index 0000000..02fae51 --- /dev/null +++ b/tests/test_release_oidc.py @@ -0,0 +1,199 @@ +"""Release authentication probe: fail closed without leaking or uploading credentials.""" + +from __future__ import annotations + +import base64 +import importlib.util +import io +import json +from pathlib import Path +from urllib.error import HTTPError, URLError + +import pytest +import yaml + +ROOT = Path(__file__).parent.parent +spec = importlib.util.spec_from_file_location("release_probe", ROOT / "scripts/verify_pypi_oidc.py") +probe = importlib.util.module_from_spec(spec) +spec.loader.exec_module(probe) +SENTINEL = "DO_NOT_LOG_TEST_CREDENTIAL" + + +def jwt(**changes): + claims = { + "iss": "https://token.actions.githubusercontent.com", "aud": "pypi", + "repository": probe.REPOSITORY, "repository_owner": "DataScience-EngineeringExperts", + "repository_owner_id": probe.OWNER_ID, + "workflow_ref": f"{probe.WORKFLOW}@refs/heads/main", + "sub": f"repo:{probe.REPOSITORY}:ref:refs/heads/main", + } | changes + body = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=") + return f"header.{body}.{SENTINEL}" + + +@pytest.fixture +def environment(monkeypatch): + monkeypatch.setenv("WARDEN_PYPI_PUBLISHER_CONFIRMED", "true") + monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://vstoken.actions.githubusercontent.com/token?x=1") + monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", SENTINEL) + monkeypatch.setenv("GITHUB_REF", "refs/heads/main") + monkeypatch.setattr(probe.time, "time", lambda: 1000) + + +def responses(monkeypatch, token=None, result=None): + requests = [] + + def fetch(request, stage): + requests.append(request) + if len(requests) == 1: + return {"value": jwt() if token is None else token} + return {"success": True, "token": "pypi-" + SENTINEL, "expires": 1900} if result is None else result + + monkeypatch.setattr(probe, "fetch_json", fetch) + return requests + + +def test_success_exchanges_only_and_never_logs_credentials(environment, monkeypatch, capsys): + requests = responses(monkeypatch) + assert probe.main() == 0 + assert len(requests) == 2 + assert requests[0].get_header("Authorization") == "Bearer " + SENTINEL + assert "audience=pypi" in requests[0].full_url + assert requests[1].full_url == "https://pypi.org/_/oidc/mint-token" + assert json.loads(requests[1].data) == {"token": jwt()} + output = capsys.readouterr().out + assert SENTINEL not in output + assert "No packages uploaded" in output + assert "authorization remains untested" in output + + +@pytest.mark.parametrize("claims", [ + {"iss": "https://attacker.invalid"}, {"aud": "sigstore"}, + {"repository": "attacker/mcp-warden"}, {"repository_owner": "attacker"}, + {"repository_owner_id": "different-owner"}, + {"workflow_ref": f"{probe.REPOSITORY}/.github/workflows/other.yml@refs/heads/main"}, + {"workflow_ref": f"{probe.WORKFLOW}@refs/heads/unreviewed"}, + {"sub": f"repo:{probe.REPOSITORY}:environment:pypi"}, + {"environment": "pypi"}, {"environment": ""}, + {"job_workflow_ref": "attacker/reusable@refs/heads/main"}, +]) +def test_mismatched_identity_never_reaches_pypi(environment, monkeypatch, capsys, claims): + requests = responses(monkeypatch, token=jwt(**claims)) + assert probe.main() == 1 + assert len(requests) == 1 + assert SENTINEL not in capsys.readouterr().out + + +@pytest.mark.parametrize("url", [ + "http://vstoken.actions.githubusercontent.com/token", + "https://actions.githubusercontent.com.attacker.invalid/token", + "https://attacker.invalid/token", "https://user@vstoken.actions.githubusercontent.com/token", + "https://vstoken.actions.githubusercontent.com:444/token", + "https://vstoken.actions.githubusercontent.com/token#fragment", +]) +def test_untrusted_endpoint_receives_no_request(environment, monkeypatch, url): + monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", url) + requests = responses(monkeypatch) + assert probe.main() == 1 + assert not requests + + +def test_redirect_is_refused_before_forwarding(): + with pytest.raises(probe.ProbeError, match="Redirect refused"): + probe.NoRedirect().redirect_request(None, None, 302, "redirect", {}, "https://attacker.invalid") + + +@pytest.mark.parametrize("result", [ + {}, {"success": False, "token": "pypi-" + SENTINEL, "expires": 1900}, + {"success": True, "token": None, "expires": 1900}, + {"success": True, "token": SENTINEL, "expires": 1900}, + {"success": True, "token": "pypi-" + SENTINEL, "expires": True}, + {"success": True, "token": "pypi-" + SENTINEL, "expires": 999}, + {"success": True, "token": "pypi-" + SENTINEL, "expires": 99999}, +]) +def test_invalid_exchange_cannot_pass(environment, monkeypatch, capsys, result): + responses(monkeypatch, result=result) + assert probe.main() == 1 + assert SENTINEL not in capsys.readouterr().out + + +@pytest.mark.parametrize("token", [SENTINEL, "a.b.c", "a.bnVsbA.c", "a.W10.c"]) +def test_malformed_jwt_fails_before_exchange(environment, monkeypatch, capsys, token): + requests = responses(monkeypatch, token=token) + assert probe.main() == 1 + assert len(requests) == 1 + assert SENTINEL not in capsys.readouterr().out + + +@pytest.mark.parametrize("body", [b"not json", b"[]", b"x" * (probe.MAX_RESPONSE + 1)]) +def test_malformed_or_oversized_json_is_rejected(body): + with pytest.raises(probe.ProbeError): + probe._json_response(io.BytesIO(body)) + + +@pytest.mark.parametrize("code", ["invalid-publisher", SENTINEL, [SENTINEL]]) +def test_http_failures_do_not_reflect_server_secrets(environment, monkeypatch, capsys, code): + body = json.dumps({"errors": [{"code": code, "description": SENTINEL}]}).encode() + + class Opener: + def open(self, *args, **kwargs): + raise HTTPError("https://pypi.org", 403, SENTINEL, {}, io.BytesIO(body)) + + monkeypatch.setattr(probe, "build_opener", lambda *args: Opener()) + assert probe.main() == 1 + output = capsys.readouterr().out + assert SENTINEL not in output + assert "HTTP 403" in output + + +@pytest.mark.parametrize("error", [URLError(SENTINEL), TimeoutError(SENTINEL), ValueError(SENTINEL)]) +def test_unexpected_network_exception_does_not_log_request(environment, monkeypatch, capsys, error): + def fetch(*args): + raise error + + monkeypatch.setattr(probe, "fetch_json", fetch) + assert probe.main() == 1 + assert SENTINEL not in capsys.readouterr().out + + +def test_missing_actions_permission_is_a_failure(monkeypatch, capsys): + monkeypatch.setenv("WARDEN_PYPI_PUBLISHER_CONFIRMED", "true") + monkeypatch.delenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", raising=False) + assert probe.main() == 1 + assert "id-token: write" in capsys.readouterr().out + + +@pytest.mark.parametrize("confirmation", ["", "false", "yes"]) +def test_unconfirmed_publisher_never_requests_tokens(environment, monkeypatch, confirmation): + monkeypatch.setenv("WARDEN_PYPI_PUBLISHER_CONFIRMED", confirmation) + requests = responses(monkeypatch) + assert probe.main() == 1 + assert not requests + + +@pytest.mark.parametrize("ref", ["refs/heads/unreviewed", "refs/tags/v2.0.0", "refs/pull/1/merge"]) +def test_unreviewed_ref_never_requests_tokens(environment, monkeypatch, ref): + monkeypatch.setenv("GITHUB_REF", ref) + requests = responses(monkeypatch) + assert probe.main() == 1 + assert not requests + + +def test_manual_verification_cannot_build_upload_sign_or_use_long_lived_credentials(): + workflow = yaml.load((ROOT / ".github/workflows/release.yml").read_text(), Loader=yaml.BaseLoader) + jobs = workflow["jobs"] + assert "verify-pypi" in workflow["on"]["workflow_dispatch"]["inputs"]["publish-target"]["options"] + assert "publish-target != 'verify-pypi'" in jobs["build"]["if"] + assert "publish-target == 'testpypi'" in jobs["pypi-publish"]["if"] + assert jobs["sign"]["if"] == "github.event_name == 'release'" + verify = jobs["pypi-verify"] + assert "publish-target == 'verify-pypi'" in verify["if"] + assert "needs" not in verify and "environment" not in verify + assert verify["permissions"] == {"id-token": "write", "contents": "read"} + assert verify["steps"][0]["run"].endswith("exit 1\n") + assert [s["run"] for s in verify["steps"][1:] if "run" in s] == ["python scripts/verify_pypi_oidc.py"] + assert "secrets." not in json.dumps(verify) + assert verify["env"]["WARDEN_PYPI_PUBLISHER_CONFIRMED"] == "${{ github.event.inputs.publisher-checked }}" + assert workflow["on"]["workflow_dispatch"]["inputs"]["publisher-checked"]["default"] == "false" + assert verify["steps"][0]["if"] == "github.ref != 'refs/heads/main'" + assert workflow["permissions"] == {"contents": "read"}