name: CI on: push: branches: [main] pull_request: workflow_dispatch: jobs: lint: runs-on: ubuntu-latest name: Lint (ruff) steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install ruff run: pip install ruff - name: ruff check run: ruff check . - name: ruff format --check run: ruff format --check . test: runs-on: ${{ matrix.os }} name: Tests (py${{ matrix.python }} / ${{ matrix.os }}) strategy: fail-fast: false matrix: os: [ubuntu-latest] python: ["3.10", "3.12", "3.13"] include: # Windows tests on 3.12 only — the version the release binaries ship # with. The self-hosted Windows runner blocks setup-python's install # script (PowerShell execution policy), so it uses the host's `py` # launcher + venv, same as release.yml. - os: windows-latest python: "3.12" steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} (Linux) if: runner.os == 'Linux' uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Set up Python venv (Windows) if: runner.os == 'Windows' shell: pwsh run: | py -${{ matrix.python }} -m venv .venv Add-Content -Path $env:GITHUB_PATH -Value "$env:GITHUB_WORKSPACE\.venv\Scripts" # bcc_core has no GUI imports, so the test suite needs no PySide6 — # keeps CI fast and avoids Qt system-library headaches on the runner. # cryptography is for tests/test_checksums.py (release signing helper). - name: Install test dependencies run: pip install pytest cryptography - name: Run tests run: python -m pytest -v # ── Catalog signature gate (#61) ───────────────────────────────────────── # # data/catalog.json is a list of command+args entries that BCC writes into # the user's Claude config, which Claude then EXECUTES. The catalog is only # trusted if it carries a valid Ed25519 signature from the maintainer key. # # The threat this gate exists for is NOT an outsider pushing to the repo — # it is the maintainer merging a friendly-looking PR without really reading # it. A contributor can change catalog.json but cannot produce a matching # signature, so a blindly-merged PR lands here as a RED BUILD within a # minute, instead of quietly riding into the next release. # # Public-key verification only. No secret is used or needed. catalog-signature: name: Catalog signature runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: pip install cryptography # 🔴 TRUST ANCHOR — issue #68 finding 4. # # This step used to do `import bcc_core as c` FROM THE CHECKED-OUT PR # BRANCH and verify the catalog against c.CATALOG_PUBKEYS — i.e. it # trusted the public key shipped in the very diff it was reviewing. A # PR that changed data/catalog.json AND bcc_core.CATALOG_PUBKEYS (to # an attacker key, with a matching signature produced by the attacker's # matching private key) went green, because there was nothing outside # the PR's own content to check the key against. The gate's whole # point is catching a friendly-looking PR the maintainer merges # without really reading it — and that hole made it a two-file diff. # # EXPECTED_CATALOG_PUBKEY_B64 below is hardcoded HERE, in the workflow # file, independent of whatever bcc_core.py says on the PR branch. It # is intentionally the only line in this step that matters for # security review: changing it changes what this gate is willing to # trust. THIS CONSTANT IS A TRUST ANCHOR. A PR that changes this line # in the same diff as a catalog change is exactly the attack this gate # exists to prevent — review a change to this line on its own, # never bundled with a catalog update. # # NOTE for the next key rotation: update EXPECTED_CATALOG_PUBKEY_B64 # below to the new key's base64 form, as its own reviewed change. - name: Verify data/catalog.json.sig env: EXPECTED_CATALOG_PUBKEY_B64: "0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k=" run: | python - <<'PY' import base64, os, pathlib, sys import bcc_core as c expected_pubkey_b64 = os.environ["EXPECTED_CATALOG_PUBKEY_B64"] raw = pathlib.Path("data/catalog.json").read_bytes() sig_path = pathlib.Path("data/catalog.json.sig") if not sig_path.exists(): sys.exit("FAIL: data/catalog.json.sig is missing. The catalog must be " "signed via the Catalog Console (#62) before it can land.") if b"\x00" * 32 in c.CATALOG_PUBKEYS: sys.exit("FAIL: CATALOG_PUBKEYS still holds the placeholder key.") # Trust anchor check FIRST, before verifying anything against # bcc_core.CATALOG_PUBKEYS: a PR is not allowed to bring its own # key. CATALOG_PUBKEYS on the checked-out branch must be EXACTLY # the key(s) this workflow file itself expects -- no more, no # fewer, no substitutions. actual_pubkeys_b64 = [base64.b64encode(k).decode() for k in c.CATALOG_PUBKEYS] if actual_pubkeys_b64 != [expected_pubkey_b64]: sys.exit( "FAIL: bcc_core.CATALOG_PUBKEYS on this branch does not match the " "trust anchor hardcoded in .github/workflows/ci.yml.\n" f" expected: {[expected_pubkey_b64]}\n" f" actual: {actual_pubkeys_b64}\n" "\n" "This PR is changing (or has changed) the catalog signing key. That " "change must be reviewed on its own, separately from any catalog " "content change, and the workflow's EXPECTED_CATALOG_PUBKEY_B64 " "updated deliberately -- not accepted because it happened to match " "whatever bcc_core.py says on this branch." ) if not c.verify_catalog_signature(raw, sig_path.read_bytes(), c.CATALOG_PUBKEYS): sys.exit( "FAIL: data/catalog.json does NOT match its signature.\n" "\n" "The catalog changed without being re-signed. Either someone edited\n" "it directly (a PR you merged?), or a signing pass was forgotten.\n" "Re-review and re-sign with the Catalog Console — do not bypass this." ) problems = c.validate_catalog(c.load_catalog(raw)) if problems: sys.exit("FAIL: catalog failed validation:\n " + "\n ".join(problems)) print("OK: catalog signature verifies, the pubkey matches the CI trust anchor, " "and the catalog validates clean.") PY