diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 417e7b21f84..69a9a115c87 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -11,6 +11,7 @@ on: - '**/sitecustomize.py' - '**/usercustomize.py' - '**/__init__.pth' + - 'pyproject.toml' permissions: pull-requests: write @@ -137,3 +138,68 @@ jobs: run: | echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details." exit 1 + + dep-bounds: + name: Check PyPI dependency upper bounds + runs-on: ubuntu-latest + if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Check for unbounded PyPI deps + id: bounds + run: | + set -euo pipefail + + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + + # Only check added lines in pyproject.toml + ADDED=$(git diff "$BASE".."$HEAD" -- pyproject.toml | grep '^+' | grep -v '^+++' || true) + + if [ -z "$ADDED" ]; then + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Match PyPI dep specs that have >= but no < ceiling. + # Pattern: "package>=version" without a following ",<" bound. + # Excludes git+ URLs (which use commit SHAs) and comments. + UNBOUNDED=$(echo "$ADDED" | grep -oE '"[a-zA-Z0-9_-]+(\[[^\]]*\])?>=[ 0-9.]+"' | grep -v ',<' || true) + + if [ -n "$UNBOUNDED" ]; then + echo "found=true" >> "$GITHUB_OUTPUT" + echo "$UNBOUNDED" > /tmp/unbounded.txt + else + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Post unbounded dep warning + if: steps.bounds.outputs.found == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BODY="## ⚠️ Unbounded PyPI Dependency Detected + + This PR adds PyPI dependencies without a \`=floor,=1.2.0,<2\"\` + + --- + *See PR #2810 and CONTRIBUTING.md for the full policy rationale.*" + + gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)" + + - name: Fail on unbounded deps + if: steps.bounds.outputs.found == 'true' + run: | + echo "::error::PyPI dependencies without upper bounds detected. Add =floor,=0.28.1,<1"` | +| Git URL | Commit SHA | `git+https://...@<40-char-sha>` | +| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@ # v4` | +| CI-only pip | `==exact` | `pyyaml==6.0.2` | + +**When adding a new dependency to `pyproject.toml`:** +1. Pin to `>=current_version,=1.5.0,<2`). +2. For pre-1.0 packages, use `<0.(current_minor + 2)` (e.g. `>=0.29,<0.32`). +3. Never commit a bare `>=X.Y.Z` without a ceiling — CI and reviewers will reject it. +4. Run `uv lock` to regenerate `uv.lock` with hashes. + +Reference: #2810 (bounds pass), #9801 (SHA pinning + audit CI). + +--- + ## Adding Configuration ### config.yaml options: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cbc26112f6..36b1e9df2d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -800,6 +800,47 @@ Hermes has terminal access. Security matters. If your PR affects security, note it explicitly in the description. +### Dependency pinning policy (supply chain hardening) + +After the [litellm supply chain compromise](https://github.com/BerriAI/litellm/issues/24512) in March 2026 and the [Mini Shai-Hulud worm campaign](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) in May 2026, all dependencies must follow these rules: + +| Source type | Required treatment | Rationale | +|---|---|---| +| **PyPI package** | `>=floor, # vX.Y.Z` | +| **CI-only pip installs** | `==exact` | Hermetic CI builds; churn is acceptable. | + +**Every new PyPI dependency in a PR must have a `=X.Y.Z` specs will be rejected by reviewers. The `supply-chain-audit.yml` CI workflow also flags dependency manifest changes for manual review. + +**How to determine the ceiling:** +- If the package is at version `1.x.y`, use `<2`. +- If the package is at version `0.x.y` (pre-1.0), use `<0.(current_minor + 2)` — e.g. if current is `0.29.x`, use `<0.32`. This gives ~2 minor versions of headroom while keeping the window small enough that a hostile takeover version is unlikely to land inside it. +- Exception: packages with very stable APIs (e.g. `aiohttp-socks`) can use `<1` at reviewer discretion. + +**Examples:** +```toml +# ✅ Correct — post-1.0 +"openai>=2.21.0,<3" +"pydantic>=2.12.5,<3" + +# ✅ Correct — pre-1.0 (tight minor window) +"asyncpg>=0.29,<0.32" +"aiosqlite>=0.20,<0.23" +"hindsight-client>=0.4.22,<0.5" + +# ❌ Rejected — no upper bound +"some-package>=1.2.3" + +# ❌ Rejected — too tight (blocks legitimate patches) +"some-package==1.2.3" + +# ❌ Rejected — too loose for pre-1.0 (allows 80 minor versions) +"some-package>=0.20,<1" +``` + +**Reference PRs:** #2796 (litellm removal), #2810 (upper bounds pass), #9801 (SHA pinning + supply-chain-audit CI). + --- ## Pull Request Process