Compare commits
132
Commits
v1.1.0
...
d4ce2647ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4ce2647ae | ||
|
|
ece8c99f79 | ||
|
|
66b0101dea | ||
|
|
c5a6bdd1d1 | ||
|
|
ded1eef2dd | ||
|
|
cdda60da1b | ||
|
|
0920846c2c | ||
|
|
ce82f7b5c3 | ||
|
|
d2c126a60c | ||
|
|
603d24566d | ||
|
|
0b2827e6b8 | ||
|
|
2e5d0351b4 | ||
|
|
2cd8e0fb3b | ||
|
|
8c51c25211 | ||
|
|
e087107710 | ||
|
|
436524bf00 | ||
|
|
e542ff6e8f | ||
|
|
dc9e035781 | ||
|
|
7368dcdbff | ||
|
|
694439b6f3 | ||
|
|
4743c4a995 | ||
|
|
8fdbe90b37 | ||
|
|
072a5cdc08 | ||
|
|
0191a93eb9 | ||
|
|
5add9b0ce0 | ||
|
|
57fd3cb6e3 | ||
|
|
4a95b370b9 | ||
|
|
f168079755 | ||
|
|
a73f2e3883 | ||
|
|
7ff4f6e5c0 | ||
|
|
7517e16b15 | ||
|
|
9a0433225e | ||
|
|
fa82d30087 | ||
|
|
05b00a40c0 | ||
|
|
3068e74e5c | ||
|
|
febd617c56 | ||
|
|
da20eb2fdb | ||
|
|
cd2ac2f6f8 | ||
|
|
26c66b7db1 | ||
|
|
86139100eb | ||
|
|
38f14deeff | ||
|
|
82483e693d | ||
|
|
6fce19cc67 | ||
|
|
37b3c8f5d0 | ||
|
|
b08cf2112b | ||
|
|
80761a1f17 | ||
|
|
d6fc6845c4 | ||
|
|
f0d0ab7a08 | ||
|
|
3841106630 | ||
|
|
e3581b6e8b | ||
|
|
672d78f903 | ||
|
|
88e93edc6c | ||
|
|
48904c7787 | ||
|
|
cd38fd0c78 | ||
|
|
e6b60e94e7 | ||
|
|
4afe21666d | ||
|
|
06e74d4d2c | ||
|
|
f92b851127 | ||
|
|
47c95ac006 | ||
|
|
6b22ad26f0 | ||
|
|
874948506c | ||
|
|
ac2e73e9d7 | ||
|
|
82ff149373 | ||
|
|
31ef4a0e85 | ||
|
|
520b1b2ffd | ||
|
|
9f535fb77f | ||
|
|
8cf19d43c4 | ||
|
|
0ef4586698 | ||
|
|
ed7c40cac9 | ||
|
|
1384ed9703 | ||
|
|
41891ddad4 | ||
|
|
408f517c5d | ||
|
|
9036729cd8 | ||
|
|
29a08e9532 | ||
|
|
87303809b8 | ||
|
|
42456f25d2 | ||
|
|
2d274b9e03 | ||
|
|
5c476bb13f | ||
|
|
62c8a2ea65 | ||
|
|
b485357cd5 | ||
|
|
6a91f830dc | ||
|
|
6dacc26057 | ||
|
|
1087fc84d1 | ||
|
|
5df364fb2e | ||
|
|
c7b2c90518 | ||
|
|
c493aa0c84 | ||
|
|
bb355dac31 | ||
|
|
d95db2b026 | ||
|
|
42963f98b4 | ||
|
|
f5c9780948 | ||
|
|
06326e5e9d | ||
|
|
6d91c709a7 | ||
|
|
3b5379a2b8 | ||
|
|
f4d4301c26 | ||
|
|
5169b7276e | ||
|
|
668fb903d0 | ||
|
|
8c456c9a89 | ||
|
|
4c6fe7c5aa | ||
|
|
8c718387c0 | ||
|
|
15a30fb986 | ||
|
|
c56dec8051 | ||
|
|
70b865be8f | ||
|
|
8fdcbda681 | ||
|
|
2d9fb083dc | ||
|
|
4ab3c3b00a | ||
|
|
3e07b51134 | ||
|
|
a811e323e6 | ||
|
|
3cd18392c9 | ||
|
|
67c898cd35 | ||
|
|
16961a5cc8 | ||
|
|
2b3843a714 | ||
|
|
256827eaf3 | ||
|
|
85d47aea97 | ||
|
|
f9752211a2 | ||
|
|
7c8fd6d0bb | ||
|
|
f1935fe320 | ||
|
|
0ffc6a1fb6 | ||
|
|
fd2c3567a0 | ||
|
|
346d0aabb6 | ||
|
|
5d59c1c423 | ||
|
|
cffdee8a40 | ||
|
|
0843c51c7d | ||
|
|
82cec27c11 | ||
|
|
6c51bac1e0 | ||
|
|
4b45251682 | ||
|
|
2a0802b22f | ||
|
|
fe66d53e9f | ||
|
|
8d90ab449d | ||
|
|
9760b1537e | ||
|
|
0f1cdbef3c | ||
|
|
165c65be5f | ||
|
|
3c65657d2f |
+128
-5
@@ -29,25 +29,148 @@ jobs:
|
|||||||
run: ruff format --check .
|
run: ruff format --check .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.os }}
|
||||||
name: Tests (py${{ matrix.python }})
|
name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python: ["3.10", "3.12"]
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python }}
|
- name: Set up Python ${{ matrix.python }} (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
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 —
|
# 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.
|
# 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
|
- name: Install test dependencies
|
||||||
run: pip install pytest
|
run: pip install pytest cryptography
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: python -m pytest -v
|
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: "082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4="
|
||||||
|
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
|
||||||
|
|||||||
@@ -95,6 +95,92 @@ jobs:
|
|||||||
name: ${{ matrix.artifact }}
|
name: ${{ matrix.artifact }}
|
||||||
path: ${{ matrix.artifact }}
|
path: ${{ matrix.artifact }}
|
||||||
|
|
||||||
|
# ── Signing-key smoke test (workflow_dispatch only) ─────────────────────
|
||||||
|
#
|
||||||
|
# The Publish job is gated on a tag, so a manual run never exercises the
|
||||||
|
# signing step — which means a wrong/missing RELEASE_SIGNING_KEY secret
|
||||||
|
# would only be discovered at the worst possible moment: during a real
|
||||||
|
# release. This job signs a throwaway manifest with the secret and verifies
|
||||||
|
# the result against scripts/sign_checksums.RELEASE_PUBKEYS.
|
||||||
|
#
|
||||||
|
# IMPORTANT (issue #68 finding 5): this must verify against the RELEASE
|
||||||
|
# public key, never bcc_core.CATALOG_PUBKEYS. The catalog key is the
|
||||||
|
# offline, maintainer-held root of trust for what BCC executes; it must
|
||||||
|
# NEVER be compared against a value that lives in a CI secret, because
|
||||||
|
# that comparison is itself a way to smuggle a catalog-trusted key through
|
||||||
|
# CI review ("does this repo secret match the catalog key" is a question
|
||||||
|
# this workflow must never even ask). The release key is a SEPARATE
|
||||||
|
# keypair, generated via `catalog_console.py keygen --release`, that only
|
||||||
|
# ever signs release SHA256SUMS manifests -- a CI/secret compromise burns
|
||||||
|
# this key, not the catalog key.
|
||||||
|
#
|
||||||
|
# It proves the two halves of the RELEASE keypair actually match, without
|
||||||
|
# publishing anything. Run it from the Actions tab after setting or
|
||||||
|
# rotating the secret.
|
||||||
|
signing-smoke-test:
|
||||||
|
name: Signing key smoke test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install cryptography
|
||||||
|
|
||||||
|
- name: Sign a throwaway manifest and verify against the RELEASE pubkey
|
||||||
|
env:
|
||||||
|
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$RELEASE_SIGNING_KEY" ]; then
|
||||||
|
echo "FAIL: RELEASE_SIGNING_KEY secret is not set."
|
||||||
|
echo "Generate the RELEASE key (NOT the catalog key) with:"
|
||||||
|
echo " python catalog_console.py keygen --release"
|
||||||
|
echo "then add its seed under Settings -> Actions -> Secrets, via:"
|
||||||
|
echo " python catalog_console.py show-seed-b64 --release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p smoke && echo "smoke test payload" > smoke/hello.txt
|
||||||
|
python3 scripts/sign_checksums.py generate smoke --out smoke/SHA256SUMS
|
||||||
|
python3 scripts/sign_checksums.py sign --sums smoke/SHA256SUMS --out smoke/SHA256SUMS.sig
|
||||||
|
python - <<'PY'
|
||||||
|
import pathlib, sys
|
||||||
|
from scripts.sign_checksums import RELEASE_PUBKEYS, verify_checksums_against_any
|
||||||
|
|
||||||
|
# Deliberately does NOT import bcc_core / CATALOG_PUBKEYS at all --
|
||||||
|
# this smoke test must never be able to compare the CI secret
|
||||||
|
# against the catalog's root of trust (issue #68 finding 5). Only
|
||||||
|
# RELEASE_PUBKEYS (scripts/sign_checksums.py) is a legitimate
|
||||||
|
# target for a CI-resident key.
|
||||||
|
if not RELEASE_PUBKEYS:
|
||||||
|
sys.exit(
|
||||||
|
"FAIL: scripts/sign_checksums.RELEASE_PUBKEYS is empty.\n"
|
||||||
|
"\n"
|
||||||
|
"Generate the release keypair with:\n"
|
||||||
|
" python catalog_console.py keygen --release\n"
|
||||||
|
"then paste the printed public key into RELEASE_PUBKEYS in\n"
|
||||||
|
"scripts/sign_checksums.py and commit that change."
|
||||||
|
)
|
||||||
|
|
||||||
|
sums = pathlib.Path("smoke/SHA256SUMS").read_text()
|
||||||
|
sig = pathlib.Path("smoke/SHA256SUMS.sig").read_bytes()
|
||||||
|
|
||||||
|
if not verify_checksums_against_any(RELEASE_PUBKEYS, sums, sig):
|
||||||
|
sys.exit(
|
||||||
|
"FAIL: the signature produced by RELEASE_SIGNING_KEY does NOT verify\n"
|
||||||
|
"against any key in scripts/sign_checksums.RELEASE_PUBKEYS.\n"
|
||||||
|
"\n"
|
||||||
|
"The secret and the shipped release public key are different keypairs.\n"
|
||||||
|
"Downloaders would reject every signature this CI produces. Re-copy the\n"
|
||||||
|
"seed from `catalog_console.py show-seed-b64 --release`, or update\n"
|
||||||
|
"RELEASE_PUBKEYS with the matching public key."
|
||||||
|
)
|
||||||
|
print("OK: RELEASE_SIGNING_KEY matches a key in RELEASE_PUBKEYS.")
|
||||||
|
PY
|
||||||
|
|
||||||
# ── Create GitHub Release with all three artifacts ──────────────────────
|
# ── Create GitHub Release with all three artifacts ──────────────────────
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@@ -107,11 +193,76 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# Needed for scripts/sign_checksums.py — the release job otherwise
|
||||||
|
# only downloads build artifacts, it doesn't check out the repo.
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Set up Python 3.12
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# download-artifact@v3 nests each artifact under a directory named
|
||||||
|
# after it (artifacts/<name>/<name>). Flatten into one directory so
|
||||||
|
# SHA256SUMS lists plain filenames, matching what `sha256sum -c`
|
||||||
|
# expects when run from inside an extracted release download.
|
||||||
|
- name: Collect release files
|
||||||
|
run: |
|
||||||
|
mkdir -p release-files
|
||||||
|
find artifacts -type f -exec cp {} release-files/ \;
|
||||||
|
ls -la release-files
|
||||||
|
|
||||||
|
- name: Generate SHA256SUMS
|
||||||
|
run: python3 scripts/sign_checksums.py generate release-files --out release-files/SHA256SUMS
|
||||||
|
|
||||||
|
# ── Sign the checksum manifest (best-effort) ──────────────────────
|
||||||
|
#
|
||||||
|
# BCC binaries are not code-signed (no budget for a paid cert). This
|
||||||
|
# is the free half: a checksum manifest, detached-signed with
|
||||||
|
# Ed25519, so a tampered download is detectable by anyone who
|
||||||
|
# checks. It does NOT remove Gatekeeper/SmartScreen warnings.
|
||||||
|
#
|
||||||
|
# The private key is a repo secret (RELEASE_SIGNING_KEY, base64 raw
|
||||||
|
# Ed25519 seed) for the RELEASE key -- a SEPARATE keypair from the
|
||||||
|
# catalog key, generated via `python catalog_console.py keygen
|
||||||
|
# --release` (issue #68 finding 5; #62). This key is intentionally
|
||||||
|
# CI-resident and signs ONLY this checksum manifest; it is never
|
||||||
|
# trusted to sign data/catalog.json. If it's not set, we still
|
||||||
|
# publish the release — just without a .sig — rather than fail the
|
||||||
|
# release outright.
|
||||||
|
- name: Check for signing key
|
||||||
|
id: signing
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ secrets.RELEASE_SIGNING_KEY }}" ]; then
|
||||||
|
echo "has_key=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "has_key=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install signing dependencies
|
||||||
|
if: steps.signing.outputs.has_key == 'true'
|
||||||
|
run: pip install cryptography
|
||||||
|
|
||||||
|
- name: Sign SHA256SUMS
|
||||||
|
if: steps.signing.outputs.has_key == 'true'
|
||||||
|
env:
|
||||||
|
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||||
|
run: |
|
||||||
|
python3 scripts/sign_checksums.py sign \
|
||||||
|
--sums release-files/SHA256SUMS \
|
||||||
|
--out release-files/SHA256SUMS.sig
|
||||||
|
|
||||||
|
- name: Warn — release will be unsigned
|
||||||
|
if: steps.signing.outputs.has_key != 'true'
|
||||||
|
run: |
|
||||||
|
echo "::warning::RELEASE_SIGNING_KEY secret is not set — this release is being published WITHOUT a signed SHA256SUMS.sig. Generate the RELEASE key (python catalog_console.py keygen --release) and add its seed (python catalog_console.py show-seed-b64 --release) as this secret before the next tag."
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
@@ -119,7 +270,9 @@ jobs:
|
|||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
generate_release_notes: false
|
generate_release_notes: false
|
||||||
files: artifacts/**/*
|
files: |
|
||||||
|
artifacts/**/*
|
||||||
|
release-files/SHA256SUMS*
|
||||||
body: |
|
body: |
|
||||||
## Better Claude Config ${{ github.ref_name }}
|
## Better Claude Config ${{ github.ref_name }}
|
||||||
|
|
||||||
@@ -139,5 +292,8 @@ jobs:
|
|||||||
xattr -cr /Applications/BetterClaudeConfig.app
|
xattr -cr /Applications/BetterClaudeConfig.app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Verifying your download
|
||||||
|
Every release includes `SHA256SUMS` (and, when the signing key is configured, a detached `SHA256SUMS.sig`). See [Verifying your download](https://git.avezzano.io/the_og/better-claude-config#verifying-your-download) in the README for commands. This proves you got the file we published — it does not remove Gatekeeper/SmartScreen warnings.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
No Python installation needed — the app is self-contained.
|
No Python installation needed — the app is self-contained.
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ The codebase is split into two layers:
|
|||||||
|
|
||||||
**`bcc_core.py`** — All logic with no GUI imports. Contains:
|
**`bcc_core.py`** — All logic with no GUI imports. Contains:
|
||||||
- `Profile` / `ServerEntry` dataclasses (the data model)
|
- `Profile` / `ServerEntry` dataclasses (the data model)
|
||||||
|
- `ClientSpec` (issue #5, cross-client) — one adapter object per MCP host capturing everything client-specific: the top-level `servers_key` (Claude uses `mcpServers`; VS Code will use `servers`), the parking `disabled_key`, config `config_filename`, the capability flags (`expands_env_refs`, `supports_restart`), and a per-server `entry_to_internal`/`entry_from_internal` translation pair (identity for Claude; the seam a differently-shaped client overrides). `CLAUDE_DESKTOP` and `CLAUDE_CODE` are the two shipped specs; `resolve_client(path)` picks one by filename, and each `Profile` carries its resolved `client`. The read/write/diff functions take an optional `spec` and default to Claude's layout, so a call with no spec is unchanged.
|
||||||
|
|
||||||
- `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude.json` (Claude Code user scope — what `claude mcp add` writes). `~/.claude/settings.json` is NOT a server config (it rejects `mcpServers` with a schema error) and is only surfaced, labelled legacy, if servers are found parked in it. Project-scope `.mcp.json` files can be opened via Add config…
|
- `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude.json` (Claude Code user scope — what `claude mcp add` writes). `~/.claude/settings.json` is NOT a server config (it rejects `mcpServers` with a schema error) and is only surfaced, labelled legacy, if servers are found parked in it. Project-scope `.mcp.json` files can be opened via Add config…
|
||||||
- `load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/`
|
- `load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/`
|
||||||
- `parse_pasted_json()` / `parse_pasted_json_verbose()` — accepts three JSON shapes (full config, inner map, or bare server object). Input does not have to be valid JSON: `repair_json_text()` auto-fixes markdown fences, surrounding prose, `//` `/* */` `#` comments, trailing/missing commas, smart quotes, single quotes, unquoted keys, Python/JS literals, and unclosed braces. The verbose variant also returns human-readable notes describing every repair applied (shown live in the paste dialog)
|
- `parse_pasted_json()` / `parse_pasted_json_verbose()` — accepts three JSON shapes (full config, inner map, or bare server object). Input does not have to be valid JSON: `repair_json_text()` auto-fixes markdown fences, surrounding prose, `//` `/* */` `#` comments, trailing/missing commas, smart quotes, single quotes, unquoted keys, Python/JS literals, and unclosed braces. The verbose variant also returns human-readable notes describing every repair applied (shown live in the paste dialog)
|
||||||
@@ -43,7 +45,7 @@ The codebase is split into two layers:
|
|||||||
- `KeyValueTable` — reusable widget for env vars and headers
|
- `KeyValueTable` — reusable widget for env vars and headers
|
||||||
- `ConnTester(QThread)` — background thread for remote reachability tests
|
- `ConnTester(QThread)` — background thread for remote reachability tests
|
||||||
|
|
||||||
**The cardinal rule**: `apply_servers()` only ever writes to `mcpServers` and `_disabledMcpServers`. All other keys in the user's config are preserved verbatim and in their original order.
|
**The cardinal rule**: `apply_servers()` only ever writes the two keys the target client's servers live under — by default `mcpServers` and `_disabledMcpServers`, or whatever the profile's `ClientSpec` declares (`servers_key` + `disabled_key`). All other keys in the user's config are preserved verbatim and in their original order. The rule generalises across clients precisely because it is parameterised by the spec rather than hard-coded.
|
||||||
|
|
||||||
Disabled servers are parked under `_disabledMcpServers` (which Claude Desktop ignores) so they can be re-enabled without losing their definition.
|
Disabled servers are parked under `_disabledMcpServers` (which Claude Desktop ignores) so they can be re-enabled without losing their definition.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# PR #87 — "Move to environment variable" (#83): manual test checklist
|
||||||
|
|
||||||
|
The logic is covered by 15 unit tests in CI; what CI **can't** exercise is the GUI (no PySide6). This checklist is only the parts a human needs to click. Should take ~10 minutes.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Documents/Claude/Projects/BetterClaudeConfig/better-claude-config
|
||||||
|
git fetch origin
|
||||||
|
git checkout feat/83-move-to-env-var
|
||||||
|
git pull # ensure you're on 8fdbe90 or later
|
||||||
|
source .venv/bin/activate # or recreate: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt
|
||||||
|
python bcc.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick a **Claude Code** profile (e.g. `~/.claude.json`) that has, or add, a server with an env value that looks like a secret — e.g. `env: { "API_KEY": "ghp_test123" }`. (You can use a throwaway value; nothing is sent anywhere.)
|
||||||
|
|
||||||
|
## The checklist
|
||||||
|
|
||||||
|
### Gating — where the action appears
|
||||||
|
- [ ] Right-click the **value cell** of a secret env row (`API_KEY`) on a **Claude Code** profile → a **"Move to environment variable…"** item appears.
|
||||||
|
- [ ] Right-click a **non-secret** row (e.g. `REGION` = `us-east-1`) → the item does **not** appear.
|
||||||
|
- [ ] Right-click a row whose value is already a reference (`${API_KEY}`) → the item does **not** appear.
|
||||||
|
- [ ] Switch to a **Claude Desktop** profile (a `claude_desktop_config.json`), right-click the same kind of secret row → the item does **not** appear. (Desktop doesn't expand `${VAR}`, so offering it would break the config — this is the important gate.)
|
||||||
|
|
||||||
|
### The dialog
|
||||||
|
- [ ] Trigger the action → dialog opens with **Variable** pre-filled from the key, sanitized to a legal shell name (e.g. `api-key` → `API_KEY`).
|
||||||
|
- [ ] Edit the variable name → the shown **shell line updates live** and matches your platform (`export VAR='…'` on macOS/Linux, `setx VAR "…"` on Windows), with the other platform shown in parentheses.
|
||||||
|
- [ ] If you type a variable name that **is already set** in your shell environment, the green "already looks set" note appears; if not, it's hidden.
|
||||||
|
- [ ] **Cancel** → nothing changes (value still the raw secret, no dirty state).
|
||||||
|
|
||||||
|
### The conversion
|
||||||
|
- [ ] **Move && copy secret** → the cell now shows the reference `${VAR}` (visible, **not** masked to dots), and the window goes dirty (Save enabled).
|
||||||
|
- [ ] Paste from your clipboard somewhere → it's the **original secret value** (handed back before removal).
|
||||||
|
- [ ] The reference value is **not** flagged as a secret warning anymore (it's the recommended state).
|
||||||
|
|
||||||
|
### Headers + persistence
|
||||||
|
- [ ] Repeat on a **remote server's Headers** table (e.g. an `Authorization` header) → same behavior.
|
||||||
|
- [ ] **Save**, then open the config file on disk in a text editor → the servers block holds `${VAR}`, and the **plaintext secret is gone** from the file.
|
||||||
|
- [ ] Re-open the profile in BCC → the row still shows `${VAR}` (round-trips).
|
||||||
|
|
||||||
|
### Undo (nice-to-have)
|
||||||
|
- [ ] After a conversion, **Ctrl+Z / Cmd+Z** restores the previous value.
|
||||||
|
|
||||||
|
## Known scope (not bugs)
|
||||||
|
- **Args rows** are out of scope for this PR — the core supports them, but the args editor is a free-text widget, so wiring that UI is a deliberate follow-up. Right-clicking args won't offer the action yet.
|
||||||
|
- The "already set" check reads **BCC's** environment, which may differ from the client's — it's advisory, worded that way.
|
||||||
|
|
||||||
|
## If anything's off
|
||||||
|
Tell me which checkbox failed and what you saw; I'll fix on the branch and re-push. If everything passes, approve/merge #87 (or tell me to merge it).
|
||||||
@@ -19,6 +19,103 @@ Pre-built self-contained binaries are attached to every [GitHub Release](../../r
|
|||||||
|
|
||||||
> **macOS Gatekeeper note:** the app is not notarized. On first launch, right-click → **Open**, or run `xattr -cr /Applications/BetterClaudeConfig.app` in a terminal.
|
> **macOS Gatekeeper note:** the app is not notarized. On first launch, right-click → **Open**, or run `xattr -cr /Applications/BetterClaudeConfig.app` in a terminal.
|
||||||
|
|
||||||
|
## Verifying your download
|
||||||
|
|
||||||
|
BCC isn't code-signed — there's no budget for a paid certificate (macOS
|
||||||
|
Developer ID, Windows Authenticode). Instead, every release publishes a
|
||||||
|
`SHA256SUMS` file listing the checksum of each archive, detached-signed with
|
||||||
|
Ed25519 as `SHA256SUMS.sig`. Both are attached to the release alongside the
|
||||||
|
binaries.
|
||||||
|
|
||||||
|
**What this proves:** the file you downloaded is byte-for-byte what we
|
||||||
|
published, and the manifest itself was signed by our release key.
|
||||||
|
|
||||||
|
**What this does NOT do:** it does not make the binary "safe," and it does
|
||||||
|
**not** remove the macOS Gatekeeper or Windows SmartScreen warning — those
|
||||||
|
are only suppressed by a paid OS-vendor certificate, which this project
|
||||||
|
doesn't have. Verifying checksums is about detecting tampering in transit or
|
||||||
|
on a mirror, not about vouching for the software.
|
||||||
|
|
||||||
|
This manifest is signed with BCC's **release key**, which is a different
|
||||||
|
key from the one that signs the MCP server catalog — see
|
||||||
|
[Signing keys](#signing-keys) below for why, and for the public key value
|
||||||
|
to use with `--pubkey-b64` below.
|
||||||
|
|
||||||
|
### macOS / Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From inside the folder you downloaded the release files into:
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
```
|
||||||
|
|
||||||
|
If your `sha256sum` complains about missing files, download `SHA256SUMS`
|
||||||
|
into the same directory as the archive you downloaded — it lists every
|
||||||
|
platform's archive, and only the one(s) present will be checked.
|
||||||
|
|
||||||
|
To also verify the manifest's signature (optional, requires Python +
|
||||||
|
`pip install cryptography` and a checkout of this repo):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/sign_checksums.py verify \
|
||||||
|
--sums SHA256SUMS --sig SHA256SUMS.sig \
|
||||||
|
--pubkey-b64 "<the release public key from Signing keys, below>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-FileHash .\BetterClaudeConfig-Windows.zip -Algorithm SHA256
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare the printed hash (case-insensitively) against the matching line in
|
||||||
|
`SHA256SUMS`.
|
||||||
|
|
||||||
|
### If a release has no `SHA256SUMS.sig`
|
||||||
|
|
||||||
|
The signing key is a repo secret that has to be configured manually; if a
|
||||||
|
release is missing the `.sig` file, the checksums themselves are still
|
||||||
|
valid and safe to check against — the release workflow only skips signing,
|
||||||
|
never checksum generation.
|
||||||
|
|
||||||
|
## Signing keys
|
||||||
|
|
||||||
|
BCC uses **two separate Ed25519 keypairs**, deliberately never the same
|
||||||
|
key, because they protect different things and live in different places:
|
||||||
|
|
||||||
|
| | Catalog key | Release key |
|
||||||
|
|---|---|---|
|
||||||
|
| Signs | `data/catalog.json` (the MCP server catalog every user's app trusts) | `SHA256SUMS` (the checksum manifest for release binaries) |
|
||||||
|
| Verified by | `bcc_core.CATALOG_PUBKEYS` | `scripts/sign_checksums.RELEASE_PUBKEYS` |
|
||||||
|
| Lives | Offline, passphrase-encrypted, maintainer's machine only (OS keychain or an encrypted file outside the repo — see the [Catalog Console](#files), issue #62) | A Gitea Actions repo secret, `RELEASE_SIGNING_KEY` — **intentionally CI-resident** |
|
||||||
|
| Generated with | `python catalog_console.py keygen` | `python catalog_console.py keygen --release` |
|
||||||
|
| Exported for CI with | *(never — there is no supported way to export this key)* | `python catalog_console.py show-seed-b64 --release` |
|
||||||
|
|
||||||
|
**Why two keys:** the catalog key is the root of trust for what BCC
|
||||||
|
actually *executes* on a user's machine — every `command`/`args` pair in
|
||||||
|
the shipped catalog is only there because this key signed it. If that key
|
||||||
|
and the release-checksum key were the same (as they briefly were — see
|
||||||
|
[issue #68](../../issues/68)), then anything that can exfiltrate a Gitea
|
||||||
|
Actions secret (a malicious workflow-file PR, a compromised runner, a leaky
|
||||||
|
log) could sign a catalog every user's copy of BCC would trust, not just a
|
||||||
|
checksum manifest. Splitting them means **a CI/secret compromise burns the
|
||||||
|
release key, never the catalog key** — checksums for a future release could
|
||||||
|
be forged, which is bad, but no attacker gains the ability to make BCC run
|
||||||
|
arbitrary commands on installs that trust the catalog. That asymmetry is
|
||||||
|
the entire point of having two keys instead of one.
|
||||||
|
|
||||||
|
The catalog key is **never** meant to leave the maintainer's machine: it's
|
||||||
|
generated, stored, unlocked, and used to sign entirely inside the Catalog
|
||||||
|
Console (`catalog_console.py`), and `catalog_console.py show-seed-b64`
|
||||||
|
refuses to run without `--release` specifically so the catalog seed can't
|
||||||
|
be exported by habit or muscle memory.
|
||||||
|
|
||||||
|
**Release signing public key** (Ed25519, base64, raw 32 bytes) — this is
|
||||||
|
the RELEASE key, not the catalog key:
|
||||||
|
|
||||||
|
```
|
||||||
|
<PLACEHOLDER — AJ: paste the release public key from `catalog_console.py keygen --release` here>
|
||||||
|
```
|
||||||
|
|
||||||
## Run from source
|
## Run from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -92,6 +189,8 @@ file is also listed, marked *legacy*, so you can copy them over.
|
|||||||
- `test_core.py` — unit suite for the core (`python test_core.py`).
|
- `test_core.py` — unit suite for the core (`python test_core.py`).
|
||||||
- `bcc.spec` — PyInstaller build spec (cross-platform).
|
- `bcc.spec` — PyInstaller build spec (cross-platform).
|
||||||
- `scripts/build_icons.py` — regenerates `icons/app.icns` and `icons/app.ico` from source PNGs.
|
- `scripts/build_icons.py` — regenerates `icons/app.icns` and `icons/app.ico` from source PNGs.
|
||||||
|
- `scripts/sign_checksums.py` — generates and Ed25519-signs the release `SHA256SUMS` manifest (see [Verifying your download](#verifying-your-download)).
|
||||||
|
- `catalog_console.py` / `catalog_review.py` — **maintainer-only**, never shipped to users (excluded from `bcc.spec`; see `tests/test_catalog_console_packaging.py`). The Catalog Console: review + sign `data/catalog.json`, and generate/manage both signing keys (`keygen`, `keygen --release`) — see [Signing keys](#signing-keys).
|
||||||
|
|
||||||
## Building from source
|
## Building from source
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ a = Analysis(
|
|||||||
["bcc.py"],
|
["bcc.py"],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=[("icons", "icons"), ("data/catalog.json", "data")],
|
||||||
hiddenimports=[],
|
hiddenimports=[],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
@@ -78,8 +78,8 @@ if sys.platform == "darwin":
|
|||||||
info_plist={
|
info_plist={
|
||||||
"CFBundleName": "Better Claude Config",
|
"CFBundleName": "Better Claude Config",
|
||||||
"CFBundleDisplayName": "Better Claude Config",
|
"CFBundleDisplayName": "Better Claude Config",
|
||||||
"CFBundleShortVersionString": "1.0.0",
|
"CFBundleShortVersionString": "1.3.0",
|
||||||
"CFBundleVersion": "1.0.0",
|
"CFBundleVersion": "1.3.0",
|
||||||
"NSHighResolutionCapable": True,
|
"NSHighResolutionCapable": True,
|
||||||
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
||||||
"LSMinimumSystemVersion": "11.0",
|
"LSMinimumSystemVersion": "11.0",
|
||||||
|
|||||||
+3647
-45
File diff suppressed because it is too large
Load Diff
+1040
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,818 @@
|
|||||||
|
"""
|
||||||
|
catalog_review.py -- pure, GUI-free review/diff/risk/crypto logic for the
|
||||||
|
Catalog Console (issue #62).
|
||||||
|
|
||||||
|
This module is deliberately Qt-free and network-free so every function in it
|
||||||
|
is unit-testable offline, exactly like bcc_core.py. catalog_console.py (the
|
||||||
|
PySide6 GUI) is a thin shell over these functions -- it owns Qt widgets,
|
||||||
|
subprocess/git calls, and HTTP registry lookups; this module owns judgment.
|
||||||
|
|
||||||
|
Nothing here is reimplemented from bcc_core: the command allowlist and the
|
||||||
|
signature domain-separation prefix are imported, not retyped, so the two
|
||||||
|
modules cannot silently drift apart (see bcc_core.validate_catalog /
|
||||||
|
bcc_core.verify_catalog_signature and the project's "the check drifted on a
|
||||||
|
new surface" recurring-bug lesson).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from bcc_core import _CATALOG_SIG_DOMAIN as CATALOG_SIG_DOMAIN
|
||||||
|
from bcc_core import CATALOG_ALLOWED_COMMANDS
|
||||||
|
from bcc_core import verify_catalog_signature as _verify_catalog_signature
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Semantic diff
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
# Top-level scalar/simple fields compared directly (not drilled into).
|
||||||
|
_DIFF_FIELDS = (
|
||||||
|
"display",
|
||||||
|
"description",
|
||||||
|
"category",
|
||||||
|
"official",
|
||||||
|
"setup",
|
||||||
|
"homepage",
|
||||||
|
"docs_url",
|
||||||
|
"source",
|
||||||
|
"notes",
|
||||||
|
"stars",
|
||||||
|
"last_release",
|
||||||
|
"env_required",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FieldChange:
|
||||||
|
"""One field that differs between the old and new version of an entry."""
|
||||||
|
|
||||||
|
field: str
|
||||||
|
old: object
|
||||||
|
new: object
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EntryChange:
|
||||||
|
"""One catalog entry's change: added, removed, or changed.
|
||||||
|
|
||||||
|
`old`/`new` are the raw entry dicts (or None for added/removed) so risk
|
||||||
|
predicates and the GUI can inspect anything not captured by
|
||||||
|
`field_changes` (which only lists fields that actually differ).
|
||||||
|
"""
|
||||||
|
|
||||||
|
entry_id: str
|
||||||
|
status: str # "added" | "removed" | "changed"
|
||||||
|
old: dict | None
|
||||||
|
new: dict | None
|
||||||
|
field_changes: tuple[FieldChange, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def _config_field_changes(old_cfg: dict | None, new_cfg: dict | None) -> list[FieldChange]:
|
||||||
|
old_cfg = old_cfg or {}
|
||||||
|
new_cfg = new_cfg or {}
|
||||||
|
changes: list[FieldChange] = []
|
||||||
|
for f in ("command", "args", "env"):
|
||||||
|
ov, nv = old_cfg.get(f), new_cfg.get(f)
|
||||||
|
if ov != nv:
|
||||||
|
changes.append(FieldChange(f"config.{f}", ov, nv))
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_field_changes(old_entry: dict, new_entry: dict) -> tuple[FieldChange, ...]:
|
||||||
|
changes: list[FieldChange] = []
|
||||||
|
for f in _DIFF_FIELDS:
|
||||||
|
ov, nv = old_entry.get(f), new_entry.get(f)
|
||||||
|
if ov != nv:
|
||||||
|
changes.append(FieldChange(f, ov, nv))
|
||||||
|
changes.extend(_config_field_changes(old_entry.get("config"), new_entry.get("config")))
|
||||||
|
return tuple(changes)
|
||||||
|
|
||||||
|
|
||||||
|
def diff_catalogs(old: dict | None, new: dict | None) -> list[EntryChange]:
|
||||||
|
"""Semantic (per-entry) diff between two parsed catalog dicts.
|
||||||
|
|
||||||
|
NOT a text diff: entries are matched by `id`, and each changed entry
|
||||||
|
reports exactly which fields differ (with before/after values), which is
|
||||||
|
what lets the Console render "command changed from X to Y" instead of a
|
||||||
|
JSON line diff a reviewer has to mentally reconstruct.
|
||||||
|
|
||||||
|
Entries missing/malformed `id` are ignored here -- that is a
|
||||||
|
validate_catalog() rejection, not a diffing concern, and diffing must not
|
||||||
|
silently invent a match for two differently-broken entries.
|
||||||
|
"""
|
||||||
|
old_servers = {
|
||||||
|
e["id"]: e
|
||||||
|
for e in (old or {}).get("servers", []) or []
|
||||||
|
if isinstance(e, dict) and isinstance(e.get("id"), str) and e.get("id")
|
||||||
|
}
|
||||||
|
new_servers = {
|
||||||
|
e["id"]: e
|
||||||
|
for e in (new or {}).get("servers", []) or []
|
||||||
|
if isinstance(e, dict) and isinstance(e.get("id"), str) and e.get("id")
|
||||||
|
}
|
||||||
|
|
||||||
|
changes: list[EntryChange] = []
|
||||||
|
for entry_id in sorted(set(old_servers) | set(new_servers)):
|
||||||
|
old_e = old_servers.get(entry_id)
|
||||||
|
new_e = new_servers.get(entry_id)
|
||||||
|
if old_e is None:
|
||||||
|
changes.append(EntryChange(entry_id, "added", None, new_e, ()))
|
||||||
|
elif new_e is None:
|
||||||
|
changes.append(EntryChange(entry_id, "removed", old_e, None, ()))
|
||||||
|
elif old_e != new_e:
|
||||||
|
fc = _entry_field_changes(old_e, new_e)
|
||||||
|
if fc:
|
||||||
|
changes.append(EntryChange(entry_id, "changed", old_e, new_e, fc))
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Risk annotations -- each predicate is pure and independently unit-tested.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RiskFinding:
|
||||||
|
severity: str # "blocking" | "warning" | "info"
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_non_ascii(s: str) -> str:
|
||||||
|
"""Render a string with any non-ASCII code point shown as an escape
|
||||||
|
sequence, so a homoglyph/RTL-override character can't visually pass as
|
||||||
|
the real thing in the review UI."""
|
||||||
|
return s.encode("unicode_escape").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def risk_env_required(change: EntryChange) -> list[RiskFinding]:
|
||||||
|
"""A non-empty env_required value is blocking: catalog entries must ship
|
||||||
|
only the *names* of env vars the user fills in, never values."""
|
||||||
|
entry = change.new or {}
|
||||||
|
env_required = entry.get("env_required")
|
||||||
|
findings: list[RiskFinding] = []
|
||||||
|
if isinstance(env_required, dict):
|
||||||
|
for k, v in env_required.items():
|
||||||
|
if v not in (None, ""):
|
||||||
|
findings.append(
|
||||||
|
RiskFinding(
|
||||||
|
"blocking",
|
||||||
|
"env_required_value",
|
||||||
|
f"env_required[{k!r}] carries a non-empty value -- catalog "
|
||||||
|
"entries must never ship secret values, only placeholder names.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def risk_command_allowlist(change: EntryChange) -> list[RiskFinding]:
|
||||||
|
"""A command outside bcc_core.CATALOG_ALLOWED_COMMANDS is blocking.
|
||||||
|
Imports the allowlist rather than redefining it."""
|
||||||
|
entry = change.new or {}
|
||||||
|
config = entry.get("config") or {}
|
||||||
|
command = config.get("command")
|
||||||
|
if isinstance(command, str) and command and command not in CATALOG_ALLOWED_COMMANDS:
|
||||||
|
return [
|
||||||
|
RiskFinding(
|
||||||
|
"blocking",
|
||||||
|
"command_not_allowed",
|
||||||
|
f"command {command!r} is not on the catalog allowlist "
|
||||||
|
f"({', '.join(sorted(CATALOG_ALLOWED_COMMANDS))}).",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def risk_non_ascii(change: EntryChange) -> list[RiskFinding]:
|
||||||
|
"""Non-ASCII code points in id/command/args are blocking -- homoglyph /
|
||||||
|
RTL-override typosquatting can make a malicious package name visually
|
||||||
|
identical to a legitimate one in a naive diff view."""
|
||||||
|
entry = change.new or {}
|
||||||
|
findings: list[RiskFinding] = []
|
||||||
|
|
||||||
|
entry_id = entry.get("id")
|
||||||
|
if isinstance(entry_id, str) and not entry_id.isascii():
|
||||||
|
findings.append(
|
||||||
|
RiskFinding(
|
||||||
|
"blocking",
|
||||||
|
"non_ascii_id",
|
||||||
|
f"id contains non-ASCII code points: {_escape_non_ascii(entry_id)!r}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
config = entry.get("config") or {}
|
||||||
|
command = config.get("command")
|
||||||
|
if isinstance(command, str) and not command.isascii():
|
||||||
|
findings.append(
|
||||||
|
RiskFinding(
|
||||||
|
"blocking",
|
||||||
|
"non_ascii_command",
|
||||||
|
f"command contains non-ASCII code points: {_escape_non_ascii(command)!r}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for a in config.get("args") or []:
|
||||||
|
if isinstance(a, str) and not a.isascii():
|
||||||
|
findings.append(
|
||||||
|
RiskFinding(
|
||||||
|
"blocking",
|
||||||
|
"non_ascii_arg",
|
||||||
|
f"arg contains non-ASCII code points: {_escape_non_ascii(a)!r}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def _npm_candidate_args(command: str | None, args: list[str]) -> list[str]:
|
||||||
|
if command != "npx":
|
||||||
|
return []
|
||||||
|
return [a for a in args if isinstance(a, str) and a and not a.startswith("-")]
|
||||||
|
|
||||||
|
|
||||||
|
def split_npm_spec(spec: str) -> tuple[str, str | None]:
|
||||||
|
"""Split an npm package spec into (name, version). version is None if
|
||||||
|
unpinned. Handles scoped (@scope/name@version) and unscoped
|
||||||
|
(name@version) specs."""
|
||||||
|
if spec.startswith("@"):
|
||||||
|
rest = spec[1:]
|
||||||
|
if "/" not in rest:
|
||||||
|
return spec, None # malformed scope, can't tell -- treat unpinned
|
||||||
|
scope, _, remainder = rest.partition("/")
|
||||||
|
if "@" in remainder:
|
||||||
|
pkg_name, _, version = remainder.partition("@")
|
||||||
|
return f"@{scope}/{pkg_name}", (version or None)
|
||||||
|
return f"@{scope}/{remainder}", None
|
||||||
|
if "@" in spec:
|
||||||
|
name, _, version = spec.partition("@")
|
||||||
|
return name, (version or None)
|
||||||
|
return spec, None
|
||||||
|
|
||||||
|
|
||||||
|
def is_pinned_npm_spec(spec: str) -> bool:
|
||||||
|
_name, version = split_npm_spec(spec)
|
||||||
|
return bool(version)
|
||||||
|
|
||||||
|
|
||||||
|
_DOCKER_VALUE_FLAGS = {
|
||||||
|
"-e",
|
||||||
|
"--env",
|
||||||
|
"-v",
|
||||||
|
"--volume",
|
||||||
|
"-p",
|
||||||
|
"--publish",
|
||||||
|
"-w",
|
||||||
|
"--workdir",
|
||||||
|
"-u",
|
||||||
|
"--user",
|
||||||
|
"--name",
|
||||||
|
"--network",
|
||||||
|
"--entrypoint",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def docker_image_candidates(args: list[str]) -> list[str]:
|
||||||
|
"""Best-effort extraction of the image reference from a `docker run
|
||||||
|
[OPTIONS] IMAGE [CMD...]` args list: the first positional token after
|
||||||
|
any leading `run` and flag(+value) pairs."""
|
||||||
|
candidates: list[str] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(args):
|
||||||
|
a = args[i]
|
||||||
|
if a == "run":
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if isinstance(a, str) and a.startswith("-"):
|
||||||
|
if "=" not in a and a in _DOCKER_VALUE_FLAGS:
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if isinstance(a, str):
|
||||||
|
candidates.append(a)
|
||||||
|
break # first positional token after `run` is the image ref
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def is_pinned_docker_image(image: str) -> bool:
|
||||||
|
if "@sha256:" in image:
|
||||||
|
return True
|
||||||
|
tag_part = image.rsplit("/", 1)[-1]
|
||||||
|
if ":" not in tag_part:
|
||||||
|
return False # no tag => implicit :latest
|
||||||
|
tag = tag_part.rsplit(":", 1)[-1]
|
||||||
|
return bool(tag) and tag != "latest"
|
||||||
|
|
||||||
|
|
||||||
|
def risk_unpinned_package(change: EntryChange) -> list[RiskFinding]:
|
||||||
|
"""Every entry must pin an exact version: an `@scope/pkg` npm arg with
|
||||||
|
no `@version`, or a docker image with no tag / `:latest`, is blocking.
|
||||||
|
A later-compromised package must not be able to auto-upgrade into every
|
||||||
|
user just because the catalog entry never pinned a version."""
|
||||||
|
entry = change.new or {}
|
||||||
|
config = entry.get("config") or {}
|
||||||
|
command = config.get("command")
|
||||||
|
args = config.get("args") or []
|
||||||
|
findings: list[RiskFinding] = []
|
||||||
|
|
||||||
|
if command == "npx":
|
||||||
|
for a in _npm_candidate_args(command, args):
|
||||||
|
if not is_pinned_npm_spec(a):
|
||||||
|
findings.append(
|
||||||
|
RiskFinding(
|
||||||
|
"blocking",
|
||||||
|
"unpinned_npm_package",
|
||||||
|
f"npm package arg {a!r} has no pinned @version.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif command == "docker":
|
||||||
|
for img in docker_image_candidates(args):
|
||||||
|
if not is_pinned_docker_image(img):
|
||||||
|
findings.append(
|
||||||
|
RiskFinding(
|
||||||
|
"blocking",
|
||||||
|
"unpinned_docker_image",
|
||||||
|
f"docker image {img!r} is not pinned to an exact tag "
|
||||||
|
"(uses :latest or no tag).",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
_URL_FIELDS = ("homepage", "docs_url", "source")
|
||||||
|
|
||||||
|
|
||||||
|
def _domain(url: str) -> str:
|
||||||
|
try:
|
||||||
|
return urlsplit(url).netloc.lower()
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def risk_url_domain_change(change: EntryChange) -> list[RiskFinding]:
|
||||||
|
"""Non-https URLs and, more importantly, a *domain change* on any URL
|
||||||
|
field are surfaced loudly with old-vs-new domains broken out -- the
|
||||||
|
lookalike-domain-swap defence."""
|
||||||
|
findings: list[RiskFinding] = []
|
||||||
|
old_entry = change.old or {}
|
||||||
|
new_entry = change.new or {}
|
||||||
|
|
||||||
|
for f in _URL_FIELDS:
|
||||||
|
new_url = new_entry.get(f)
|
||||||
|
if not isinstance(new_url, str) or not new_url:
|
||||||
|
continue
|
||||||
|
if not new_url.startswith("https://"):
|
||||||
|
findings.append(
|
||||||
|
RiskFinding("warning", "non_https_url", f"{f} is not https://: {new_url!r}")
|
||||||
|
)
|
||||||
|
old_url = old_entry.get(f)
|
||||||
|
if isinstance(old_url, str) and old_url:
|
||||||
|
old_domain, new_domain = _domain(old_url), _domain(new_url)
|
||||||
|
if old_domain and new_domain and old_domain != new_domain:
|
||||||
|
findings.append(
|
||||||
|
RiskFinding(
|
||||||
|
"warning",
|
||||||
|
"domain_changed",
|
||||||
|
f"{f} domain changed from {old_domain!r} to {new_domain!r} -- "
|
||||||
|
"verify this isn't a lookalike-domain swap.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def risk_new_entry(change: EntryChange) -> list[RiskFinding]:
|
||||||
|
"""A brand-new entry is flagged for extra scrutiny -- not blocking on its
|
||||||
|
own, but it's the category of change the registry lookup exists for."""
|
||||||
|
if change.status == "added":
|
||||||
|
return [
|
||||||
|
RiskFinding(
|
||||||
|
"info",
|
||||||
|
"new_entry",
|
||||||
|
"Brand-new catalog entry -- extra scrutiny: check publisher identity "
|
||||||
|
"via the registry lookup before signing.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
_RISK_PREDICATES: tuple[Callable[[EntryChange], list[RiskFinding]], ...] = (
|
||||||
|
risk_env_required,
|
||||||
|
risk_command_allowlist,
|
||||||
|
risk_non_ascii,
|
||||||
|
risk_unpinned_package,
|
||||||
|
risk_url_domain_change,
|
||||||
|
risk_new_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def entry_risk_findings(change: EntryChange) -> list[RiskFinding]:
|
||||||
|
"""Run every risk predicate against one entry change and return the
|
||||||
|
combined findings (order matches _RISK_PREDICATES)."""
|
||||||
|
findings: list[RiskFinding] = []
|
||||||
|
for predicate in _RISK_PREDICATES:
|
||||||
|
findings.extend(predicate(change))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def has_blocking_risk(change: EntryChange) -> bool:
|
||||||
|
return any(f.severity == "blocking" for f in entry_risk_findings(change))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Review session: acknowledge-gating + TOCTOU blob-SHA pinning
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReviewSession:
|
||||||
|
"""State for one review pass. `pinned_blob_sha` is the git blob SHA of
|
||||||
|
data/catalog.json as it existed the moment review began -- see
|
||||||
|
can_sign()/sign_precondition().
|
||||||
|
|
||||||
|
`loaded_ref` is the exact ref this review was loaded from ("main", or a
|
||||||
|
PR's `refs/pull/<n>/head`) -- see issue #68 finding 1. It exists so the
|
||||||
|
Sign path can re-resolve the TOCTOU blob SHA from *the ref that was
|
||||||
|
actually reviewed*, instead of a hardcoded "main" that silently diverges
|
||||||
|
from the reviewed ref on every PR review (the bug that made the PR path
|
||||||
|
unable to sign at all, and forced everyone onto the vacuous
|
||||||
|
main-vs-itself path instead).
|
||||||
|
"""
|
||||||
|
|
||||||
|
pinned_blob_sha: str
|
||||||
|
old_catalog: dict
|
||||||
|
new_catalog: dict
|
||||||
|
loaded_ref: str = "main"
|
||||||
|
changes: list[EntryChange] = field(default_factory=list)
|
||||||
|
acknowledged: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.changes:
|
||||||
|
self.changes = diff_catalogs(self.old_catalog, self.new_catalog)
|
||||||
|
|
||||||
|
|
||||||
|
def start_review(
|
||||||
|
pinned_blob_sha: str,
|
||||||
|
old_catalog: dict,
|
||||||
|
new_catalog: dict,
|
||||||
|
loaded_ref: str = "main",
|
||||||
|
) -> ReviewSession:
|
||||||
|
return ReviewSession(
|
||||||
|
pinned_blob_sha=pinned_blob_sha,
|
||||||
|
old_catalog=old_catalog,
|
||||||
|
new_catalog=new_catalog,
|
||||||
|
loaded_ref=loaded_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_last_signed_catalog_raw(
|
||||||
|
candidates: list[bytes], sig: bytes, pubkeys: list[bytes]
|
||||||
|
) -> bytes | None:
|
||||||
|
"""Given `candidates` (candidate raw catalog.json byte-strings -- e.g.
|
||||||
|
successive historical versions from git log, most-recent-first),
|
||||||
|
return the first one whose signature verifies against `sig`/`pubkeys`,
|
||||||
|
or None if none do.
|
||||||
|
|
||||||
|
This is how source="main" review diffs against "the last catalog a
|
||||||
|
maintainer actually signed" instead of against itself (issue #68
|
||||||
|
finding 1): `catalog_console.last_signed_catalog_raw` walks
|
||||||
|
data/catalog.json's git history on main and hands the candidates here.
|
||||||
|
"""
|
||||||
|
for raw in candidates:
|
||||||
|
if _verify_catalog_signature(raw, sig, pubkeys):
|
||||||
|
return raw
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def acknowledge_entry(session: ReviewSession, entry_id: str) -> None:
|
||||||
|
ids = {c.entry_id for c in session.changes}
|
||||||
|
if entry_id not in ids:
|
||||||
|
raise ValueError(f"{entry_id!r} is not part of this review session's diff.")
|
||||||
|
session.acknowledged.add(entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
def unacknowledge_entry(session: ReviewSession, entry_id: str) -> None:
|
||||||
|
session.acknowledged.discard(entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
def all_entries_acknowledged(session: ReviewSession) -> bool:
|
||||||
|
return {c.entry_id for c in session.changes} <= session.acknowledged
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE for future editors: do NOT add an "acknowledge all" shortcut here, now
|
||||||
|
# or ever. The friction of individually acknowledging every changed entry is
|
||||||
|
# the entire point of this tool (issue #62) -- a shortcut would let a tired
|
||||||
|
# reviewer rubber-stamp a diff exactly like the "merge PR, run script, push"
|
||||||
|
# reflex this Console exists to replace. If this comment is the only thing
|
||||||
|
# stopping you, that is the point: it is stopping you on purpose.
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SignDecision:
|
||||||
|
ok: bool
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def can_sign(session: ReviewSession, current_blob_sha: str) -> SignDecision:
|
||||||
|
"""Whether the Sign button may fire right now.
|
||||||
|
|
||||||
|
Four independent gates, all required, checked in this order:
|
||||||
|
|
||||||
|
0. The diff must be non-empty. An empty diff historically meant "Sign
|
||||||
|
unlocks instantly" (`set() <= set()` is vacuously True), which is
|
||||||
|
exactly backwards: a vacuously-satisfied gate is worse than no gate
|
||||||
|
at all, because it *manufactures confidence* -- the signature looks
|
||||||
|
identical to one produced by a real review. "Nothing changed" must
|
||||||
|
mean "nothing to sign", never "sign unlocked". (Issue #68 finding 1;
|
||||||
|
this is what let commit b08cf21 sign all 19 entries with zero of them
|
||||||
|
ever reviewed.)
|
||||||
|
1. TOCTOU: `current_blob_sha` (fetched fresh, immediately before signing,
|
||||||
|
from the ref that was actually reviewed -- see sign_precondition())
|
||||||
|
must match the blob SHA pinned when review began. If the bytes on the
|
||||||
|
remote changed since -- a new commit pushed to the same PR, a
|
||||||
|
force-push, another PR merged in between -- signing is refused and a
|
||||||
|
re-review is forced. This is what makes "signing is the approval act"
|
||||||
|
true rather than aspirational: the signature is bound to the exact
|
||||||
|
reviewed bytes, not to "whatever the file happens to be now".
|
||||||
|
2. No blocking risk finding may be outstanding on ANY changed entry, full
|
||||||
|
stop -- checked here, not just in the GUI. The GUI additionally
|
||||||
|
disables the acknowledge checkbox for a blocking entry, but that is a
|
||||||
|
UI nicety, not the enforcement point: if this pure gate didn't also
|
||||||
|
check it, a blocking risk would only be stopped by the GUI happening
|
||||||
|
to have wired the checkbox correctly, and nothing would catch a
|
||||||
|
regression in that wiring. The GUI must not be the only thing
|
||||||
|
standing between a blocking risk and a signature.
|
||||||
|
3. Every changed entry in the diff must be individually acknowledged.
|
||||||
|
"""
|
||||||
|
if not session.changes:
|
||||||
|
return SignDecision(
|
||||||
|
False,
|
||||||
|
"Nothing to sign: this review's diff is empty. If you expected "
|
||||||
|
"changes here, you may be diffing the wrong source/ref.",
|
||||||
|
)
|
||||||
|
if current_blob_sha != session.pinned_blob_sha:
|
||||||
|
return SignDecision(
|
||||||
|
False,
|
||||||
|
"The reviewed bytes changed since this review began (blob SHA "
|
||||||
|
"mismatch) -- re-review required before signing.",
|
||||||
|
)
|
||||||
|
blocking_ids = sorted({c.entry_id for c in session.changes if has_blocking_risk(c)})
|
||||||
|
if blocking_ids:
|
||||||
|
return SignDecision(
|
||||||
|
False,
|
||||||
|
"Blocking risk finding(s) outstanding on: "
|
||||||
|
f"{', '.join(blocking_ids)} -- fix the underlying change, do not sign around it.",
|
||||||
|
)
|
||||||
|
if not all_entries_acknowledged(session):
|
||||||
|
pending = sorted({c.entry_id for c in session.changes} - session.acknowledged)
|
||||||
|
return SignDecision(
|
||||||
|
False, f"Not every changed entry has been acknowledged yet: {', '.join(pending)}"
|
||||||
|
)
|
||||||
|
return SignDecision(True, None)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_precondition(
|
||||||
|
session: ReviewSession, resolve_blob_sha: Callable[[str], str]
|
||||||
|
) -> SignDecision:
|
||||||
|
"""The real Sign-button gate: resolves the current TOCTOU blob SHA from
|
||||||
|
*the ref this session was actually loaded from* (`session.loaded_ref`),
|
||||||
|
never a hardcoded "main", then delegates to can_sign().
|
||||||
|
|
||||||
|
`resolve_blob_sha` is injected so this stays testable without git/Qt --
|
||||||
|
catalog_console.ReviewWindow._on_sign passes a real resolver
|
||||||
|
(fetch_ref + blob_sha_at against self.repo_dir); tests pass a fake
|
||||||
|
dict-backed lookup. This is the fix for issue #68 finding 1's first bug:
|
||||||
|
`_on_sign` used to hardcode `fetch_ref(self.repo_dir, "main")` as the
|
||||||
|
comparison ref, so for any PR review (where `loaded_ref` is the PR's
|
||||||
|
head, not main) the SHAs differed by definition and Sign could never
|
||||||
|
fire -- and the retry path re-called the same hardcoded resolver, so it
|
||||||
|
re-pinned the same wrong value and looped forever instead of forcing a
|
||||||
|
genuine re-review.
|
||||||
|
"""
|
||||||
|
current_blob_sha = resolve_blob_sha(session.loaded_ref)
|
||||||
|
return can_sign(session, current_blob_sha)
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_signing_message(raw_bytes: bytes) -> bytes:
|
||||||
|
"""The exact bytes that get signed: bcc_core's domain-separation prefix
|
||||||
|
(imported, never retyped) + the raw catalog bytes. Using this function
|
||||||
|
guarantees the Console's signature and bcc_core.verify_catalog_signature
|
||||||
|
can never drift apart on the prefix."""
|
||||||
|
return CATALOG_SIG_DOMAIN + raw_bytes
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Key management: passphrase-encrypted-at-rest Ed25519 seed
|
||||||
|
#
|
||||||
|
# The private key is NEVER stored plaintext, never an env var, never
|
||||||
|
# committed. encrypt_private_key/decrypt_private_key are pure and offline
|
||||||
|
# (scrypt KDF + AES-256-GCM via `cryptography`, already a project
|
||||||
|
# dependency); catalog_console.py decides WHERE the resulting blob lives
|
||||||
|
# (OS keychain if available, else a file outside the repo).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_KDF_SALT_LEN = 16
|
||||||
|
_KDF_N = 2**15 # scrypt cost parameter, tuned for a one-off interactive unlock
|
||||||
|
_KDF_R = 8
|
||||||
|
_KDF_P = 1
|
||||||
|
_NONCE_LEN = 12
|
||||||
|
_AAD = b"bcc-catalog-console-key-v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_key(passphrase: str, salt: bytes) -> bytes:
|
||||||
|
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
||||||
|
|
||||||
|
kdf = Scrypt(salt=salt, length=32, n=_KDF_N, r=_KDF_R, p=_KDF_P)
|
||||||
|
return kdf.derive(passphrase.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_keypair() -> tuple[bytes, bytes]:
|
||||||
|
"""Generate a new Ed25519 keypair. Returns (seed_32_bytes, pubkey_32_bytes)."""
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||||
|
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
seed = private_key.private_bytes_raw()
|
||||||
|
pubkey = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||||
|
return seed, pubkey
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_private_key(seed: bytes, passphrase: str) -> bytes:
|
||||||
|
"""Encrypt a 32-byte Ed25519 seed at rest with a passphrase. Returns a
|
||||||
|
self-contained blob: salt || nonce || ciphertext+tag."""
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
if len(seed) != 32:
|
||||||
|
raise ValueError(f"expected a 32-byte raw Ed25519 seed, got {len(seed)} bytes")
|
||||||
|
if not passphrase:
|
||||||
|
raise ValueError("a non-empty passphrase is required")
|
||||||
|
salt = os.urandom(_KDF_SALT_LEN)
|
||||||
|
key = _derive_key(passphrase, salt)
|
||||||
|
nonce = os.urandom(_NONCE_LEN)
|
||||||
|
ciphertext = AESGCM(key).encrypt(nonce, seed, _AAD)
|
||||||
|
return salt + nonce + ciphertext
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_private_key(blob: bytes, passphrase: str) -> bytes:
|
||||||
|
"""Decrypt a blob produced by encrypt_private_key. Raises ValueError on a
|
||||||
|
wrong passphrase or corrupt blob -- never silently returns garbage."""
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
if len(blob) < _KDF_SALT_LEN + _NONCE_LEN:
|
||||||
|
raise ValueError("key blob is too short to be valid")
|
||||||
|
salt = blob[:_KDF_SALT_LEN]
|
||||||
|
nonce = blob[_KDF_SALT_LEN : _KDF_SALT_LEN + _NONCE_LEN]
|
||||||
|
ciphertext = blob[_KDF_SALT_LEN + _NONCE_LEN :]
|
||||||
|
key = _derive_key(passphrase, salt)
|
||||||
|
try:
|
||||||
|
return AESGCM(key).decrypt(nonce, ciphertext, _AAD)
|
||||||
|
except InvalidTag as e:
|
||||||
|
raise ValueError("wrong passphrase or corrupted key file") from e
|
||||||
|
|
||||||
|
|
||||||
|
def sign_catalog_bytes(raw: bytes, seed: bytes) -> bytes:
|
||||||
|
"""Sign `raw` catalog bytes with a 32-byte Ed25519 seed, using the exact
|
||||||
|
domain-separated message bcc_core.verify_catalog_signature expects."""
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
if len(seed) != 32:
|
||||||
|
raise ValueError(f"expected a 32-byte raw Ed25519 seed, got {len(seed)} bytes")
|
||||||
|
private_key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||||
|
return private_key.sign(catalog_signing_message(raw))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Registry lookup -- the check a human genuinely can't do.
|
||||||
|
#
|
||||||
|
# The network call itself is injected (a `Fetcher` callable) so this stays
|
||||||
|
# testable offline; catalog_console.py supplies the real npm/PyPI HTTP
|
||||||
|
# fetcher. Fails soft everywhere: a fetcher returning None/raising just
|
||||||
|
# yields RegistryInfo(available=False), never an exception into the caller
|
||||||
|
# and never a block on review.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PackageRef:
|
||||||
|
entry_id: str
|
||||||
|
ecosystem: str # "npm" | "pypi"
|
||||||
|
name: str
|
||||||
|
version: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def split_pypi_spec(spec: str) -> tuple[str, str | None]:
|
||||||
|
for sep in ("==", "@"):
|
||||||
|
if sep in spec:
|
||||||
|
name, _, version = spec.partition(sep)
|
||||||
|
return name, (version or None)
|
||||||
|
return spec, None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_package_refs(entry: dict) -> list[PackageRef]:
|
||||||
|
"""Pull out the package(s) a basic-tier entry's args reference, for the
|
||||||
|
registry lookup. Returns [] for link-only entries or entries whose
|
||||||
|
command isn't npx/uvx (docker images aren't registry-lookup candidates
|
||||||
|
in the npm/PyPI sense used here)."""
|
||||||
|
config = entry.get("config") or {}
|
||||||
|
command = config.get("command")
|
||||||
|
args = config.get("args") or []
|
||||||
|
entry_id = entry.get("id", "") if isinstance(entry.get("id"), str) else ""
|
||||||
|
refs: list[PackageRef] = []
|
||||||
|
|
||||||
|
if command == "npx":
|
||||||
|
for a in _npm_candidate_args(command, args):
|
||||||
|
name, version = split_npm_spec(a)
|
||||||
|
if name:
|
||||||
|
refs.append(PackageRef(entry_id, "npm", name, version))
|
||||||
|
elif command == "uvx":
|
||||||
|
for a in args:
|
||||||
|
if isinstance(a, str) and a and not a.startswith("-"):
|
||||||
|
name, version = split_pypi_spec(a)
|
||||||
|
if name:
|
||||||
|
refs.append(PackageRef(entry_id, "pypi", name, version))
|
||||||
|
break # `uvx <pkg>` -- first positional token is the package
|
||||||
|
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RegistryInfo:
|
||||||
|
ref: PackageRef
|
||||||
|
available: bool
|
||||||
|
publisher: str | None = None
|
||||||
|
age_days: int | None = None
|
||||||
|
last_release: str | None = None
|
||||||
|
downloads: int | None = None
|
||||||
|
near_neighbor_ids: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
Fetcher = Callable[[PackageRef], dict | None]
|
||||||
|
|
||||||
|
|
||||||
|
def edit_distance(a: str, b: str) -> int:
|
||||||
|
"""Levenshtein distance, iterative DP (no recursion depth concerns)."""
|
||||||
|
if a == b:
|
||||||
|
return 0
|
||||||
|
la, lb = len(a), len(b)
|
||||||
|
if la == 0:
|
||||||
|
return lb
|
||||||
|
if lb == 0:
|
||||||
|
return la
|
||||||
|
prev = list(range(lb + 1))
|
||||||
|
for i, ca in enumerate(a, 1):
|
||||||
|
cur = [i] + [0] * lb
|
||||||
|
for j, cb in enumerate(b, 1):
|
||||||
|
cost = 0 if ca == cb else 1
|
||||||
|
cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
|
||||||
|
prev = cur
|
||||||
|
return prev[lb]
|
||||||
|
|
||||||
|
|
||||||
|
def near_neighbor_ids(name: str, other_ids: list[str], max_distance: int = 2) -> list[str]:
|
||||||
|
"""Catalog ids within `max_distance` edits of `name` (case-insensitive),
|
||||||
|
excluding an exact match -- the dependency-confusion / typosquat
|
||||||
|
near-neighbour warning."""
|
||||||
|
lname = name.lower()
|
||||||
|
return [
|
||||||
|
oid
|
||||||
|
for oid in other_ids
|
||||||
|
if oid != name and edit_distance(lname, oid.lower()) <= max_distance
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_registry_info(
|
||||||
|
ref: PackageRef, fetcher: Fetcher, all_entry_ids: list[str]
|
||||||
|
) -> RegistryInfo:
|
||||||
|
"""Resolve one package against the live registry via the injected
|
||||||
|
fetcher. Never raises: any fetcher exception or falsy return means
|
||||||
|
`available=False` ("unavailable"), which the GUI renders plainly rather
|
||||||
|
than blocking or erroring the review."""
|
||||||
|
neighbors = tuple(near_neighbor_ids(ref.name, all_entry_ids))
|
||||||
|
try:
|
||||||
|
raw = fetcher(ref)
|
||||||
|
except Exception:
|
||||||
|
raw = None
|
||||||
|
if not raw:
|
||||||
|
return RegistryInfo(ref=ref, available=False, near_neighbor_ids=neighbors)
|
||||||
|
return RegistryInfo(
|
||||||
|
ref=ref,
|
||||||
|
available=True,
|
||||||
|
publisher=raw.get("publisher"),
|
||||||
|
age_days=raw.get("age_days"),
|
||||||
|
last_release=raw.get("last_release"),
|
||||||
|
downloads=raw.get("downloads"),
|
||||||
|
near_neighbor_ids=neighbors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_NON_ASCII_RE = re.compile(r"[^\x00-\x7f]")
|
||||||
|
|
||||||
|
|
||||||
|
def contains_non_ascii(s: str) -> bool:
|
||||||
|
return bool(_NON_ASCII_RE.search(s))
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"version": 1,
|
||||||
|
"updated": "2026-07-12",
|
||||||
|
"signed_at": "2026-07-12T21:35:19Z",
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"id": "filesystem",
|
||||||
|
"display": "Filesystem",
|
||||||
|
"description": "Lets Claude read, write, search, and edit files in directories you explicitly allow.",
|
||||||
|
"category": "files",
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
|
||||||
|
"stars": 85995,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem@2026.7.10",
|
||||||
|
"<ALLOWED_DIR>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"<ALLOWED_DIR>": "Absolute path to a directory the server may read/write. Add more directories as additional args."
|
||||||
|
},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
|
||||||
|
"notes": "Part of the official modelcontextprotocol/servers reference monorepo (star count is for the whole repo). Clients that support MCP 'roots' can also grant directories dynamically instead of via args.",
|
||||||
|
"last_release": "2026-07-10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fetch",
|
||||||
|
"display": "Fetch",
|
||||||
|
"description": "Fetches a URL and converts the page to clean markdown so Claude can read web content.",
|
||||||
|
"category": "dev",
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
|
||||||
|
"stars": 85995,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"mcp-server-fetch@2026.7.10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
|
||||||
|
"notes": "Can access local/internal IPs, so treat as a mild security risk on untrusted networks. Add '--ignore-robots-txt' or '--user-agent=...' as extra args if needed.",
|
||||||
|
"last_release": "2026-07-10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "memory",
|
||||||
|
"display": "Memory",
|
||||||
|
"description": "Gives Claude a persistent knowledge-graph memory that survives across conversations.",
|
||||||
|
"category": "ai",
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
|
||||||
|
"stars": 85995,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-memory@2026.7.4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
|
||||||
|
"notes": "Stores entities/relations as a local JSON knowledge graph (path configurable via MEMORY_FILE_PATH env var).",
|
||||||
|
"last_release": "2026-07-04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sequential-thinking",
|
||||||
|
"display": "Sequential Thinking",
|
||||||
|
"description": "Gives Claude a structured, step-by-step reasoning tool for breaking down complex problems.",
|
||||||
|
"category": "ai",
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
|
||||||
|
"stars": 85995,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-sequential-thinking@2026.7.4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
|
||||||
|
"notes": "Set DISABLE_THOUGHT_LOGGING=true to stop it from printing each thought step to the console.",
|
||||||
|
"last_release": "2026-07-04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "git",
|
||||||
|
"display": "Git",
|
||||||
|
"description": "Lets Claude read history, diff, branch, and search a local git repository.",
|
||||||
|
"category": "dev",
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
|
||||||
|
"stars": 85995,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"mcp-server-git@2026.7.10",
|
||||||
|
"--repository",
|
||||||
|
"<REPO_PATH>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"<REPO_PATH>": "Absolute path to the local git repository"
|
||||||
|
},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
|
||||||
|
"notes": "Operates on a local checkout only; it does not talk to GitHub/GitLab APIs (pair with the GitHub or GitLab server for that).",
|
||||||
|
"last_release": "2026-07-10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "github",
|
||||||
|
"display": "GitHub",
|
||||||
|
"description": "Lets Claude read repos/code, and manage issues, pull requests, and Actions on GitHub.",
|
||||||
|
"category": "code-hosting",
|
||||||
|
"homepage": "https://github.com/github/github-mcp-server",
|
||||||
|
"stars": 30202,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"-i",
|
||||||
|
"--rm",
|
||||||
|
"-e",
|
||||||
|
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||||
|
"ghcr.io/github/github-mcp-server:v1.0.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"GITHUB_PERSONAL_ACCESS_TOKEN": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/github/github-mcp-server/blob/main/docs/installation-guides/README.md",
|
||||||
|
"notes": "The old '@modelcontextprotocol/server-github' npm package is discontinued (deprecated April 2025). GitHub now ships a Docker-based local server (requires Docker installed/running) plus a hosted remote server at https://api.githubcopilot.com/mcp/ that supports OAuth or PAT auth without Docker."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "playwright",
|
||||||
|
"display": "Playwright",
|
||||||
|
"description": "Lets Claude drive a real browser (click, type, navigate, screenshot) using Playwright's accessibility-tree snapshots.",
|
||||||
|
"category": "browser",
|
||||||
|
"homepage": "https://github.com/microsoft/playwright-mcp",
|
||||||
|
"stars": 34000,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@0.0.78"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/microsoft/playwright-mcp#readme",
|
||||||
|
"notes": "Maintained by the Playwright team at Microsoft. Add '--isolated' for a throwaway profile, or '--browser firefox|webkit|msedge' to change engine. A persistent browser profile is used by default so logins carry over between sessions.",
|
||||||
|
"last_release": "2026-07-09"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chrome-devtools",
|
||||||
|
"display": "Chrome DevTools",
|
||||||
|
"description": "Lets Claude control Chrome and use real DevTools features: performance traces, network inspection, console logs, screenshots.",
|
||||||
|
"category": "browser",
|
||||||
|
"homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp",
|
||||||
|
"stars": 45000,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"chrome-devtools-mcp@1.5.0"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/ChromeDevTools/chrome-devtools-mcp#readme",
|
||||||
|
"notes": "Maintained by the Google Chrome DevTools team; only officially supports Google Chrome / Chrome for Testing. Exposes the browser's content to the MCP client, so avoid sensitive sites while connected. Add '--slim --headless' for a minimal 3-tool basic-automation mode.",
|
||||||
|
"last_release": "2026-07-03"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "postgres",
|
||||||
|
"display": "Postgres MCP Pro",
|
||||||
|
"description": "Lets Claude query, inspect schema, and analyze/tune performance of a PostgreSQL database.",
|
||||||
|
"category": "database",
|
||||||
|
"homepage": "https://github.com/crystaldba/postgres-mcp",
|
||||||
|
"stars": 2400,
|
||||||
|
"official": false,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"postgres-mcp@0.3.0",
|
||||||
|
"--access-mode=restricted"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"DATABASE_URI": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/crystaldba/postgres-mcp#readme",
|
||||||
|
"notes": "The official reference Postgres server was archived by the MCP team; this community server (Crystal DBA) is the most capable/most-referenced replacement, adding index tuning and EXPLAIN-plan analysis. Use --access-mode=restricted for read-only/production use. Docker image also available (crystaldba/postgres-mcp). Catalog ships --access-mode=restricted (read-only); switch to unrestricted yourself if you want writes.",
|
||||||
|
"last_release": "2025-05-16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n8n",
|
||||||
|
"display": "n8n",
|
||||||
|
"description": "Build, validate, and deploy n8n workflows with full node documentation for the AI.",
|
||||||
|
"category": "infra",
|
||||||
|
"homepage": "https://github.com/czlonkowski/n8n-mcp",
|
||||||
|
"stars": 22257,
|
||||||
|
"official": false,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"n8n-mcp@2.63.2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"MCP_MODE": "",
|
||||||
|
"N8N_API_URL": "",
|
||||||
|
"N8N_API_KEY": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/czlonkowski/n8n-mcp",
|
||||||
|
"notes": "Set MCP_MODE=stdio (required for Claude Desktop, prevents debug logs from breaking the protocol). N8N_API_URL/N8N_API_KEY are optional — without them you still get full node documentation, validation, and template search; with them you get live workflow create/update/execute against your own n8n instance. A hosted free-tier alternative exists at dashboard.n8n-mcp.com.",
|
||||||
|
"last_release": "2026-07-09"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notion",
|
||||||
|
"display": "Notion",
|
||||||
|
"description": "Read, search, and edit Notion pages, databases, and comments from your AI assistant.",
|
||||||
|
"category": "productivity",
|
||||||
|
"homepage": "https://github.com/makenotion/notion-mcp-server",
|
||||||
|
"stars": 4400,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@notionhq/notion-mcp-server@2.4.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"NOTION_TOKEN": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://developers.notion.com/docs/mcp",
|
||||||
|
"notes": "Notion is prioritizing its hosted remote MCP (OAuth, https://mcp.notion.com/mcp) and may eventually sunset this local package, but the stdio server still works today and is the simplest way to get a static config with an internal-integration token.",
|
||||||
|
"last_release": "2026-06-22"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "obsidian",
|
||||||
|
"display": "Obsidian",
|
||||||
|
"description": "Read, search, and edit notes in your Obsidian vault.",
|
||||||
|
"category": "personal",
|
||||||
|
"homepage": "https://github.com/MarkusPfundstein/mcp-obsidian",
|
||||||
|
"stars": 4067,
|
||||||
|
"official": false,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"mcp-obsidian@0.2.2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"OBSIDIAN_API_KEY": "",
|
||||||
|
"OBSIDIAN_HOST": "",
|
||||||
|
"OBSIDIAN_PORT": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/MarkusPfundstein/mcp-obsidian",
|
||||||
|
"notes": "Requires the Obsidian Local REST API community plugin installed and enabled in Obsidian; copy the API key from the plugin settings. OBSIDIAN_HOST defaults to 127.0.0.1 and OBSIDIAN_PORT to 27124 if omitted.",
|
||||||
|
"last_release": "2025-04-01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "brave-search",
|
||||||
|
"display": "Brave Search",
|
||||||
|
"description": "Search the web, news, images, and videos using Brave's independent search index.",
|
||||||
|
"category": "search",
|
||||||
|
"homepage": "https://github.com/brave/brave-search-mcp-server",
|
||||||
|
"stars": 1288,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@brave/brave-search-mcp-server@2.0.85",
|
||||||
|
"--transport",
|
||||||
|
"stdio"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"BRAVE_API_KEY": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/brave/brave-search-mcp-server",
|
||||||
|
"notes": "Official Brave server; replaced the old archived modelcontextprotocol/servers brave-search entry (now in modelcontextprotocol/servers-archived). Get an API key from the Brave Search API dashboard.",
|
||||||
|
"last_release": "2026-06-15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tavily",
|
||||||
|
"display": "Tavily",
|
||||||
|
"description": "AI-optimized web search, extract, map, and crawl API built for LLM agents.",
|
||||||
|
"category": "search",
|
||||||
|
"homepage": "https://github.com/tavily-ai/tavily-mcp",
|
||||||
|
"stars": 2206,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"tavily-mcp@0.2.21"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"TAVILY_API_KEY": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/tavily-ai/tavily-mcp",
|
||||||
|
"notes": "Tavily also offers a hosted remote MCP endpoint (mcp.tavily.com) with OAuth as an alternative to running the local npx server.",
|
||||||
|
"last_release": "2026-07-10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "home-assistant",
|
||||||
|
"display": "Home Assistant",
|
||||||
|
"description": "Control smart home devices, query states, and troubleshoot automations in Home Assistant.",
|
||||||
|
"category": "smart-home",
|
||||||
|
"homepage": "https://github.com/voska/hass-mcp",
|
||||||
|
"stars": 308,
|
||||||
|
"official": false,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"-i",
|
||||||
|
"--rm",
|
||||||
|
"-e",
|
||||||
|
"HA_URL",
|
||||||
|
"-e",
|
||||||
|
"HA_TOKEN",
|
||||||
|
"voska/hass-mcp:0.5.0"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"HA_URL": "",
|
||||||
|
"HA_TOKEN": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/voska/hass-mcp",
|
||||||
|
"notes": "HA_URL example: http://homeassistant.local:8123 (use http://host.docker.internal:8123 if HA runs in Docker on the same machine). HA_TOKEN is a Home Assistant long-lived access token from your profile page. A more actively developed alternative is the community 'HA-MCP' integration (homeassistant-ai/ha-mcp, ~3.9k stars), but it installs inside Home Assistant itself via HACS rather than as an external stdio process, so it doesn't fit this catalog's launch-line format."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kubernetes",
|
||||||
|
"display": "Kubernetes",
|
||||||
|
"description": "Lets Claude inspect and manage Kubernetes/OpenShift resources — pods, deployments, logs, Helm releases — using your local kubeconfig.",
|
||||||
|
"category": "infra",
|
||||||
|
"homepage": "https://github.com/containers/kubernetes-mcp-server",
|
||||||
|
"stars": 1626,
|
||||||
|
"official": false,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"kubernetes-mcp-server@0.0.64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/containers/kubernetes-mcp-server#readme",
|
||||||
|
"notes": "Not an official Kubernetes SIG project, but a Go-native (no kubectl dependency) implementation maintained under the 'containers' GitHub org (Podman/Red Hat-adjacent) that's widely regarded as the most capable K8s MCP server, supporting Kubernetes and OpenShift. Uses your existing ~/.kube/config automatically; add --read-only to prevent writes.",
|
||||||
|
"last_release": "2026-07-10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "aws-api-mcp-server",
|
||||||
|
"display": "AWS API MCP Server (AWS Labs)",
|
||||||
|
"description": "Lets your AI assistant run AWS CLI commands to inspect and manage AWS resources across virtually every AWS service.",
|
||||||
|
"category": "cloud",
|
||||||
|
"homepage": "https://github.com/awslabs/mcp",
|
||||||
|
"stars": 9431,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"awslabs.aws-api-mcp-server@1.3.46"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://awslabs.github.io/mcp/servers/aws-api-mcp-server",
|
||||||
|
"notes": "AWS credentials are NOT set in this MCP config — configure them beforehand via `aws configure` (or set AWS_API_MCP_PROFILE_NAME to pick a named profile) so boto3's standard credential chain can find them. Optional env vars: AWS_REGION (default us-east-1), READ_OPERATIONS_ONLY=true to block all mutating AWS calls. AWS notes this server is being superseded by a newer unified AWS MCP server referenced in their agent-toolkit docs.",
|
||||||
|
"last_release": "2026-06-25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grafana",
|
||||||
|
"display": "Grafana",
|
||||||
|
"description": "Query dashboards, datasources, alerts and incidents in Grafana from your AI assistant.",
|
||||||
|
"category": "observability",
|
||||||
|
"homepage": "https://github.com/grafana/mcp-grafana",
|
||||||
|
"stars": 3227,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"mcp-grafana@0.17.1"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"GRAFANA_URL": "<GRAFANA_URL>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"<GRAFANA_URL>": "Your Grafana instance URL, e.g. http://localhost:3000 or https://yourstack.grafana.net"
|
||||||
|
},
|
||||||
|
"env_required": {
|
||||||
|
"GRAFANA_SERVICE_ACCOUNT_TOKEN": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://grafana.com/docs/grafana/latest/developer-resources/mcp/",
|
||||||
|
"notes": "Requires Grafana 9.0+ for full functionality — datasource-related tools may not work correctly on older versions.",
|
||||||
|
"last_release": "2026-07-07"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "slack",
|
||||||
|
"display": "Slack",
|
||||||
|
"description": "Search Slack messages, files, and members, and send messages or manage channels from your AI assistant.",
|
||||||
|
"category": "communication",
|
||||||
|
"homepage": "https://slack.com/help/articles/48855576908307-Guide-to-the-Slack-MCP-server",
|
||||||
|
"stars": null,
|
||||||
|
"official": true,
|
||||||
|
"setup": "link-only",
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://docs.slack.dev/changelog/2026/02/17/slack-mcp/",
|
||||||
|
"notes": "Slack's own MCP server went GA Feb 17, 2026 (streamable HTTP at https://mcp.slack.com/mcp, OAuth). No stdio one-liner is published because it's a hosted, permissioned connector. A well-known community alternative, korotovsky/slack-mcp-server (~1.6k GitHub stars, MIT, not an official Slack product), supports stdio/SSE/HTTP with bot or browser-session tokens and no app-install requirement if a stdio option is preferred."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ы<8¶ђt2ішл„»‰/НЕ0Тjcw&`
|
||||||
|
тrH«MўК›єrBL,0AS€!Х2–иже.SТ°ч–'Agm
|
||||||
+2
-1
@@ -1,12 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "better-claude-config"
|
name = "better-claude-config"
|
||||||
version = "1.1.0"
|
version = "1.3.0"
|
||||||
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
|
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PySide6>=6.6",
|
"PySide6>=6.6",
|
||||||
|
"cryptography>=42.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Runtime (also in requirements.txt)
|
# Runtime (also in requirements.txt)
|
||||||
PySide6>=6.6
|
PySide6>=6.6
|
||||||
|
cryptography>=42.0 # catalog signature verification (bcc_core) + release checksum signing
|
||||||
|
|
||||||
# Build / packaging
|
# Build / packaging
|
||||||
pyinstaller>=6.0
|
pyinstaller>=6.0
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
PySide6>=6.6
|
PySide6>=6.6
|
||||||
|
cryptography>=42.0 # bcc_core imports it at load (catalog signature verification)
|
||||||
|
|||||||
Executable
+267
@@ -0,0 +1,267 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate a SHA256SUMS file for release artifacts and sign it with Ed25519.
|
||||||
|
|
||||||
|
BCC ships PyInstaller binaries that are not code-signed (no budget for a
|
||||||
|
macOS Developer ID / Windows Authenticode certificate). This script provides
|
||||||
|
the free half of supply-chain integrity: a checksum manifest, detached-signed
|
||||||
|
so downloaders can verify the file they got is the file we published.
|
||||||
|
|
||||||
|
This does NOT remove Gatekeeper/SmartScreen warnings and does NOT prove the
|
||||||
|
binary is safe to run -- only that it matches what the release signing key
|
||||||
|
attested to.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Hash every file in a directory into a SHA256SUMS-format manifest.
|
||||||
|
python scripts/sign_checksums.py generate <dir> --out SHA256SUMS
|
||||||
|
|
||||||
|
# Sign a manifest, producing a detached signature.
|
||||||
|
# Private key comes from $RELEASE_SIGNING_KEY (base64 raw Ed25519 seed)
|
||||||
|
# unless --key-b64 is given explicitly (mostly for tests).
|
||||||
|
python scripts/sign_checksums.py sign --sums SHA256SUMS --out SHA256SUMS.sig
|
||||||
|
|
||||||
|
# Verify a manifest against a detached signature and a public key.
|
||||||
|
python scripts/sign_checksums.py verify --sums SHA256SUMS --sig SHA256SUMS.sig \
|
||||||
|
--pubkey-b64 <base64 raw Ed25519 public key>
|
||||||
|
|
||||||
|
The private key is generated and rotated via the Catalog Console (#62) --
|
||||||
|
this script never generates or stores a key itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Domain separation prefix: ties every signature to "a BCC release checksum
|
||||||
|
# manifest" so a signature can never be replayed against an unrelated
|
||||||
|
# message signed by the same key.
|
||||||
|
DOMAIN_PREFIX = b"bcc-release-v1|"
|
||||||
|
|
||||||
|
# Public half of the RELEASE signing key(s) -- a SEPARATE keypair from
|
||||||
|
# bcc_core.CATALOG_PUBKEYS (issue #68 finding 5). The catalog key is the
|
||||||
|
# offline, Console-only root of trust for what BCC executes; this key is
|
||||||
|
# CI-resident and signs ONLY the release SHA256SUMS manifest, never the
|
||||||
|
# catalog. Keeping them apart means a CI/repo-secret compromise burns the
|
||||||
|
# release key -- annoying, but it never lets an attacker sign a catalog a
|
||||||
|
# user's binary would trust. A LIST (not a single key), mirroring
|
||||||
|
# CATALOG_PUBKEYS, so the release key can be rotated without invalidating
|
||||||
|
# the signature on every past release: verification accepts a match against
|
||||||
|
# ANY key here.
|
||||||
|
#
|
||||||
|
# Empty until the maintainer generates the release keypair (separately from
|
||||||
|
# the catalog keypair) and pastes the public half in:
|
||||||
|
# python catalog_console.py keygen --release
|
||||||
|
# This is intentionally NOT pre-populated with a placeholder that looks
|
||||||
|
# like a real key -- release.yml's signing-smoke-test fails closed (loudly)
|
||||||
|
# on an empty list rather than silently verifying against nothing.
|
||||||
|
RELEASE_PUBKEYS: list[bytes] = []
|
||||||
|
|
||||||
|
CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
"""Return the lowercase hex SHA-256 digest of a file's contents."""
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
while chunk := fh.read(CHUNK_SIZE):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def build_checksums_text(files: dict[str, str]) -> str:
|
||||||
|
"""Build a sha256sum(1)-compatible manifest body.
|
||||||
|
|
||||||
|
`files` maps filename -> hex digest. Entries are sorted by filename for
|
||||||
|
a deterministic, diffable output. Format matches `sha256sum` exactly:
|
||||||
|
"<hash> <filename>\n" (two spaces, no path components).
|
||||||
|
"""
|
||||||
|
lines = [f"{digest} {name}" for name, digest in sorted(files.items())]
|
||||||
|
body = "\n".join(lines)
|
||||||
|
return body + "\n" if body else ""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_checksums(directory: Path, *, exclude: set[str] | None = None) -> str:
|
||||||
|
"""Hash every regular file directly inside `directory` (non-recursive)
|
||||||
|
and return the SHA256SUMS text. Filenames are recorded without any
|
||||||
|
directory prefix so the manifest can be verified from inside the
|
||||||
|
directory it describes.
|
||||||
|
"""
|
||||||
|
exclude = exclude or set()
|
||||||
|
files: dict[str, str] = {}
|
||||||
|
for entry in sorted(directory.iterdir()):
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
if entry.name in exclude:
|
||||||
|
continue
|
||||||
|
files[entry.name] = sha256_file(entry)
|
||||||
|
return build_checksums_text(files)
|
||||||
|
|
||||||
|
|
||||||
|
def _signing_message(sums_text: str) -> bytes:
|
||||||
|
"""The exact bytes that get signed: the domain prefix followed by the
|
||||||
|
raw bytes of the SHA256SUMS file content."""
|
||||||
|
return DOMAIN_PREFIX + sums_text.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_checksums(seed_b64: str, sums_text: str) -> bytes:
|
||||||
|
"""Sign `sums_text` with the Ed25519 private key encoded (base64, raw
|
||||||
|
32-byte seed) in `seed_b64`. Returns the raw 64-byte signature."""
|
||||||
|
# Imported lazily so `generate` mode (used on every CI run) never
|
||||||
|
# requires the `cryptography` package to be installed.
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
seed = base64.b64decode(seed_b64)
|
||||||
|
if len(seed) != 32:
|
||||||
|
raise ValueError(f"expected a 32-byte raw Ed25519 seed, got {len(seed)} bytes")
|
||||||
|
private_key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||||
|
return private_key.sign(_signing_message(sums_text))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_checksums(pubkey_b64: str, sums_text: str, signature: bytes) -> bool:
|
||||||
|
"""Verify `signature` over `sums_text` against the base64-encoded raw
|
||||||
|
32-byte Ed25519 public key. Returns True/False; never raises for a bad
|
||||||
|
signature (only for malformed inputs)."""
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
|
||||||
|
pubkey_bytes = base64.b64decode(pubkey_b64)
|
||||||
|
if len(pubkey_bytes) != 32:
|
||||||
|
raise ValueError(
|
||||||
|
f"expected a 32-byte raw Ed25519 public key, got {len(pubkey_bytes)} bytes"
|
||||||
|
)
|
||||||
|
public_key = Ed25519PublicKey.from_public_bytes(pubkey_bytes)
|
||||||
|
try:
|
||||||
|
public_key.verify(signature, _signing_message(sums_text))
|
||||||
|
return True
|
||||||
|
except InvalidSignature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def public_key_b64_from_seed(seed_b64: str) -> str:
|
||||||
|
"""Derive the base64 raw public key from a base64 raw seed. Handy for
|
||||||
|
local key-pair sanity checks; not used by the release workflow."""
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||||
|
|
||||||
|
seed = base64.b64decode(seed_b64)
|
||||||
|
private_key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||||
|
raw = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||||
|
return base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_checksums_against_any(pubkeys: list[bytes], sums_text: str, signature: bytes) -> bool:
|
||||||
|
"""Verify `signature` against ANY key in `pubkeys` (each a raw 32-byte
|
||||||
|
Ed25519 public key). Mirrors bcc_core.verify_catalog_signature's
|
||||||
|
rotation-friendly "any currently-trusted key" semantics, applied to
|
||||||
|
RELEASE_PUBKEYS instead of the catalog's key list. Returns False (never
|
||||||
|
raises) for an empty `pubkeys` list -- fails closed rather than
|
||||||
|
vacuously verifying against nothing."""
|
||||||
|
return any(
|
||||||
|
verify_checksums(base64.b64encode(pk).decode("ascii"), sums_text, signature)
|
||||||
|
for pk in pubkeys
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _cmd_generate(args: argparse.Namespace) -> int:
|
||||||
|
directory = Path(args.directory)
|
||||||
|
exclude = {"SHA256SUMS", "SHA256SUMS.sig"}
|
||||||
|
text = generate_checksums(directory, exclude=exclude)
|
||||||
|
out_path = Path(args.out)
|
||||||
|
out_path.write_text(text, encoding="utf-8")
|
||||||
|
print(f"Wrote {out_path} ({len(text.splitlines())} entries)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_sign(args: argparse.Namespace) -> int:
|
||||||
|
seed_b64 = args.key_b64 or os.environ.get(args.key_env, "")
|
||||||
|
if not seed_b64:
|
||||||
|
print(
|
||||||
|
f"error: no signing key provided (checked --key-b64 and ${args.key_env})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
sums_text = Path(args.sums).read_text(encoding="utf-8")
|
||||||
|
signature = sign_checksums(seed_b64, sums_text)
|
||||||
|
Path(args.out).write_bytes(signature)
|
||||||
|
print(f"Wrote {args.out} ({len(signature)} bytes)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_verify(args: argparse.Namespace) -> int:
|
||||||
|
pubkey_b64 = args.pubkey_b64 or os.environ.get(args.pubkey_env, "")
|
||||||
|
if not pubkey_b64:
|
||||||
|
print(
|
||||||
|
f"error: no public key provided (checked --pubkey-b64 and ${args.pubkey_env})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
sums_text = Path(args.sums).read_text(encoding="utf-8")
|
||||||
|
signature = Path(args.sig).read_bytes()
|
||||||
|
ok = verify_checksums(pubkey_b64, sums_text, signature)
|
||||||
|
if ok:
|
||||||
|
print("OK: signature is valid")
|
||||||
|
return 0
|
||||||
|
print("FAILED: signature is invalid", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="mode", required=True)
|
||||||
|
|
||||||
|
p_gen = sub.add_parser(
|
||||||
|
"generate", help="hash every file in a directory into a SHA256SUMS manifest"
|
||||||
|
)
|
||||||
|
p_gen.add_argument("directory", help="directory whose files should be hashed (non-recursive)")
|
||||||
|
p_gen.add_argument("--out", required=True, help="path to write the SHA256SUMS manifest to")
|
||||||
|
p_gen.set_defaults(func=_cmd_generate)
|
||||||
|
|
||||||
|
p_sign = sub.add_parser("sign", help="detached-sign a SHA256SUMS manifest with Ed25519")
|
||||||
|
p_sign.add_argument("--sums", required=True, help="path to the SHA256SUMS manifest to sign")
|
||||||
|
p_sign.add_argument("--out", required=True, help="path to write the detached signature to")
|
||||||
|
p_sign.add_argument(
|
||||||
|
"--key-b64", default=None, help="base64 raw Ed25519 seed (overrides --key-env)"
|
||||||
|
)
|
||||||
|
p_sign.add_argument(
|
||||||
|
"--key-env",
|
||||||
|
default="RELEASE_SIGNING_KEY",
|
||||||
|
help="environment variable holding the base64 seed (default: RELEASE_SIGNING_KEY)",
|
||||||
|
)
|
||||||
|
p_sign.set_defaults(func=_cmd_sign)
|
||||||
|
|
||||||
|
p_verify = sub.add_parser(
|
||||||
|
"verify", help="verify a SHA256SUMS manifest against a detached signature"
|
||||||
|
)
|
||||||
|
p_verify.add_argument("--sums", required=True, help="path to the SHA256SUMS manifest")
|
||||||
|
p_verify.add_argument("--sig", required=True, help="path to the detached signature")
|
||||||
|
p_verify.add_argument(
|
||||||
|
"--pubkey-b64", default=None, help="base64 raw Ed25519 public key (overrides --pubkey-env)"
|
||||||
|
)
|
||||||
|
p_verify.add_argument(
|
||||||
|
"--pubkey-env",
|
||||||
|
default="RELEASE_SIGNING_PUBKEY",
|
||||||
|
help="environment variable holding the base64 public key (default: RELEASE_SIGNING_PUBKEY)",
|
||||||
|
)
|
||||||
|
p_verify.set_defaults(func=_cmd_verify)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Asserts the maintainer-only Catalog Console (catalog_console.py,
|
||||||
|
catalog_review.py) is never bundled into the release binary.
|
||||||
|
|
||||||
|
A signing/review tool shipping to end users would be an own-goal (issue
|
||||||
|
#62): it has no reason to run on a user's machine, and its presence would
|
||||||
|
be a confusing artefact of a build that's supposed to be a thin GUI over
|
||||||
|
mcpServers config editing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SPEC_PATH = REPO_ROOT / "bcc.spec"
|
||||||
|
|
||||||
|
_EXCLUDED_FILES = ("catalog_console.py", "catalog_review.py")
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_file_exists():
|
||||||
|
assert SPEC_PATH.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_console_files_not_named_in_spec():
|
||||||
|
"""The spec text must never reference either maintainer-only module --
|
||||||
|
not as the Analysis entry point, not in datas, not anywhere."""
|
||||||
|
spec_text = SPEC_PATH.read_text(encoding="utf-8")
|
||||||
|
for filename in _EXCLUDED_FILES:
|
||||||
|
assert filename not in spec_text, (
|
||||||
|
f"{filename} must never be referenced by bcc.spec -- it is a "
|
||||||
|
"maintainer-only tool and must not ship to users."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_analysis_entry_point_is_bcc_py_only():
|
||||||
|
"""PyInstaller's Analysis(...) call determines the dependency-scanned
|
||||||
|
entry point(s); it must be bcc.py alone."""
|
||||||
|
spec_text = SPEC_PATH.read_text(encoding="utf-8")
|
||||||
|
assert 'Analysis(\n ["bcc.py"],' in spec_text or 'Analysis(["bcc.py"]' in spec_text, (
|
||||||
|
"bcc.spec's Analysis(...) entry point changed shape -- re-verify by hand "
|
||||||
|
"that catalog_console.py / catalog_review.py are still excluded."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_console_modules_exist_but_are_standalone_top_level_files():
|
||||||
|
"""Sanity check the files this test is guarding actually exist as
|
||||||
|
top-level modules (not, say, silently moved into a package PyInstaller's
|
||||||
|
Analysis would still pick up as an implicit import of bcc.py)."""
|
||||||
|
for filename in _EXCLUDED_FILES:
|
||||||
|
assert (REPO_ROOT / filename).exists()
|
||||||
|
# bcc.py must not import them.
|
||||||
|
bcc_text = (REPO_ROOT / "bcc.py").read_text(encoding="utf-8")
|
||||||
|
module_name = filename.removesuffix(".py")
|
||||||
|
assert f"import {module_name}" not in bcc_text
|
||||||
|
assert f"from {module_name}" not in bcc_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_requirements_files_do_not_reference_console_only_needs():
|
||||||
|
"""catalog_console.py's only import beyond the shipped stack is the
|
||||||
|
optional `keyring` package, which is intentionally NOT added as a hard
|
||||||
|
dependency anywhere a user install would pick it up."""
|
||||||
|
for req_file in ("requirements.txt", "requirements-dev.txt"):
|
||||||
|
path = REPO_ROOT / req_file
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8").lower()
|
||||||
|
assert "keyring" not in text
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
"""Tests for catalog_review.py -- semantic diff, risk predicates, review
|
||||||
|
session (acknowledge-gating + TOCTOU blob pinning), key encryption, and
|
||||||
|
registry-lookup logic for the Catalog Console (issue #62)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import bcc_core as c
|
||||||
|
import catalog_review as r
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(**overrides):
|
||||||
|
base = {
|
||||||
|
"id": "filesystem",
|
||||||
|
"display": "Filesystem",
|
||||||
|
"description": "desc",
|
||||||
|
"category": "files",
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/servers",
|
||||||
|
"stars": 100,
|
||||||
|
"official": True,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem@1.0.0"],
|
||||||
|
},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/modelcontextprotocol/servers",
|
||||||
|
"notes": "",
|
||||||
|
"last_release": "2026-01-01",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog(*entries):
|
||||||
|
return {"schema": 1, "version": 1, "servers": list(entries)}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# diff_catalogs
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_diff_detects_added_entry():
|
||||||
|
old = _catalog()
|
||||||
|
new = _catalog(_entry())
|
||||||
|
changes = r.diff_catalogs(old, new)
|
||||||
|
assert len(changes) == 1
|
||||||
|
assert changes[0].status == "added"
|
||||||
|
assert changes[0].entry_id == "filesystem"
|
||||||
|
assert changes[0].old is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_detects_removed_entry():
|
||||||
|
old = _catalog(_entry())
|
||||||
|
new = _catalog()
|
||||||
|
changes = r.diff_catalogs(old, new)
|
||||||
|
assert len(changes) == 1
|
||||||
|
assert changes[0].status == "removed"
|
||||||
|
assert changes[0].new is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_detects_no_change():
|
||||||
|
e = _entry()
|
||||||
|
old = _catalog(e)
|
||||||
|
new = _catalog(dict(e))
|
||||||
|
assert r.diff_catalogs(old, new) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_detects_changed_command_and_args():
|
||||||
|
old = _catalog(_entry())
|
||||||
|
new = _catalog(_entry(config={"command": "uvx", "args": ["other-pkg@2.0.0"]}))
|
||||||
|
changes = r.diff_catalogs(old, new)
|
||||||
|
assert len(changes) == 1
|
||||||
|
ch = changes[0]
|
||||||
|
assert ch.status == "changed"
|
||||||
|
fields = {fc.field for fc in ch.field_changes}
|
||||||
|
assert "config.command" in fields
|
||||||
|
assert "config.args" in fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_detects_description_change():
|
||||||
|
old = _catalog(_entry())
|
||||||
|
new = _catalog(_entry(description="new description"))
|
||||||
|
changes = r.diff_catalogs(old, new)
|
||||||
|
assert changes[0].field_changes == (r.FieldChange("description", "desc", "new description"),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_ignores_entries_without_id():
|
||||||
|
old = _catalog()
|
||||||
|
new = _catalog({"display": "no id"})
|
||||||
|
assert r.diff_catalogs(old, new) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_multiple_entries_sorted_by_id():
|
||||||
|
old = _catalog(_entry(id="zeta"), _entry(id="alpha"))
|
||||||
|
new = _catalog(
|
||||||
|
_entry(id="zeta", description="changed"), _entry(id="alpha", description="changed")
|
||||||
|
)
|
||||||
|
changes = r.diff_catalogs(old, new)
|
||||||
|
assert [c_.entry_id for c_ in changes] == ["alpha", "zeta"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# risk_env_required
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_risk_env_required_blocking_on_nonempty_value():
|
||||||
|
change = r.EntryChange("x", "changed", None, _entry(env_required={"API_KEY": "sk-real-value"}))
|
||||||
|
findings = r.risk_env_required(change)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0].severity == "blocking"
|
||||||
|
assert findings[0].code == "env_required_value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_env_required_clean_on_empty_value():
|
||||||
|
change = r.EntryChange("x", "changed", None, _entry(env_required={"API_KEY": ""}))
|
||||||
|
assert r.risk_env_required(change) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# risk_command_allowlist
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_risk_command_allowlist_blocks_disallowed_command():
|
||||||
|
change = r.EntryChange(
|
||||||
|
"x", "changed", None, _entry(config={"command": "bash", "args": ["-c", "evil"]})
|
||||||
|
)
|
||||||
|
findings = r.risk_command_allowlist(change)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0].severity == "blocking"
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_command_allowlist_allows_listed_command():
|
||||||
|
for cmd in sorted(c.CATALOG_ALLOWED_COMMANDS):
|
||||||
|
change = r.EntryChange("x", "changed", None, _entry(config={"command": cmd, "args": []}))
|
||||||
|
assert r.risk_command_allowlist(change) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# risk_non_ascii
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_risk_non_ascii_flags_homoglyph_id():
|
||||||
|
# Cyrillic 'а' (U+0430) instead of Latin 'a' -- classic homoglyph swap.
|
||||||
|
evil_id = "filаsystem"
|
||||||
|
change = r.EntryChange(evil_id, "changed", None, _entry(id=evil_id))
|
||||||
|
findings = r.risk_non_ascii(change)
|
||||||
|
assert any(f.code == "non_ascii_id" for f in findings)
|
||||||
|
assert findings[0].severity == "blocking"
|
||||||
|
# the offending string must be rendered with escapes, not raw
|
||||||
|
assert "\\u0430" in findings[0].message
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_non_ascii_flags_arg():
|
||||||
|
change = r.EntryChange(
|
||||||
|
"x", "changed", None, _entry(config={"command": "npx", "args": ["pаckage@1.0.0"]})
|
||||||
|
)
|
||||||
|
findings = r.risk_non_ascii(change)
|
||||||
|
assert any(f.code == "non_ascii_arg" for f in findings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_non_ascii_clean_for_ascii_entry():
|
||||||
|
change = r.EntryChange("x", "changed", None, _entry())
|
||||||
|
assert r.risk_non_ascii(change) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# risk_unpinned_package
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_risk_unpinned_npm_package_no_version():
|
||||||
|
change = r.EntryChange(
|
||||||
|
"x",
|
||||||
|
"changed",
|
||||||
|
None,
|
||||||
|
_entry(config={"command": "npx", "args": ["-y", "@scope/pkg"]}),
|
||||||
|
)
|
||||||
|
findings = r.risk_unpinned_package(change)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0].code == "unpinned_npm_package"
|
||||||
|
assert findings[0].severity == "blocking"
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_pinned_npm_package_is_clean():
|
||||||
|
change = r.EntryChange(
|
||||||
|
"x",
|
||||||
|
"changed",
|
||||||
|
None,
|
||||||
|
_entry(config={"command": "npx", "args": ["-y", "@scope/pkg@1.2.3"]}),
|
||||||
|
)
|
||||||
|
assert r.risk_unpinned_package(change) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_unpinned_unscoped_npm_package():
|
||||||
|
change = r.EntryChange(
|
||||||
|
"x", "changed", None, _entry(config={"command": "npx", "args": ["-y", "somepkg"]})
|
||||||
|
)
|
||||||
|
findings = r.risk_unpinned_package(change)
|
||||||
|
assert len(findings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_unpinned_docker_latest_tag():
|
||||||
|
change = r.EntryChange(
|
||||||
|
"x",
|
||||||
|
"changed",
|
||||||
|
None,
|
||||||
|
_entry(config={"command": "docker", "args": ["run", "-i", "--rm", "myimage:latest"]}),
|
||||||
|
)
|
||||||
|
findings = r.risk_unpinned_package(change)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0].code == "unpinned_docker_image"
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_unpinned_docker_no_tag():
|
||||||
|
change = r.EntryChange(
|
||||||
|
"x", "changed", None, _entry(config={"command": "docker", "args": ["run", "myimage"]})
|
||||||
|
)
|
||||||
|
findings = r.risk_unpinned_package(change)
|
||||||
|
assert len(findings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_pinned_docker_image_is_clean():
|
||||||
|
change = r.EntryChange(
|
||||||
|
"x",
|
||||||
|
"changed",
|
||||||
|
None,
|
||||||
|
_entry(config={"command": "docker", "args": ["run", "-i", "--rm", "myimage:1.2.3"]}),
|
||||||
|
)
|
||||||
|
assert r.risk_unpinned_package(change) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_docker_digest_pin_is_clean():
|
||||||
|
change = r.EntryChange(
|
||||||
|
"x",
|
||||||
|
"changed",
|
||||||
|
None,
|
||||||
|
_entry(
|
||||||
|
config={
|
||||||
|
"command": "docker",
|
||||||
|
"args": ["run", "myimage@sha256:" + "a" * 64],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert r.risk_unpinned_package(change) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# risk_url_domain_change
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_risk_url_domain_change_warns_on_lookalike_swap():
|
||||||
|
old_entry = _entry(homepage="https://github.com/foo/bar")
|
||||||
|
new_entry = _entry(homepage="https://githu6.com/foo/bar")
|
||||||
|
change = r.EntryChange("x", "changed", old_entry, new_entry)
|
||||||
|
findings = r.risk_url_domain_change(change)
|
||||||
|
assert any(f.code == "domain_changed" for f in findings)
|
||||||
|
domain_finding = next(f for f in findings if f.code == "domain_changed")
|
||||||
|
assert "github.com" in domain_finding.message
|
||||||
|
assert "githu6.com" in domain_finding.message
|
||||||
|
assert domain_finding.severity == "warning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_url_domain_change_clean_when_domain_unchanged():
|
||||||
|
old_entry = _entry(homepage="https://github.com/foo/bar")
|
||||||
|
new_entry = _entry(homepage="https://github.com/foo/bar-renamed")
|
||||||
|
change = r.EntryChange("x", "changed", old_entry, new_entry)
|
||||||
|
assert r.risk_url_domain_change(change) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_url_non_https_warns():
|
||||||
|
new_entry = _entry(homepage="http://example.com")
|
||||||
|
change = r.EntryChange("x", "changed", None, new_entry)
|
||||||
|
findings = r.risk_url_domain_change(change)
|
||||||
|
assert any(f.code == "non_https_url" for f in findings)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# risk_new_entry / entry_risk_findings / has_blocking_risk
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_risk_new_entry_flags_added():
|
||||||
|
change = r.EntryChange("x", "added", None, _entry())
|
||||||
|
findings = r.risk_new_entry(change)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0].severity == "info"
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_new_entry_silent_for_changed():
|
||||||
|
change = r.EntryChange("x", "changed", _entry(), _entry(description="x"))
|
||||||
|
assert r.risk_new_entry(change) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_blocking_risk_true_for_disallowed_command():
|
||||||
|
change = r.EntryChange("x", "changed", None, _entry(config={"command": "bash", "args": []}))
|
||||||
|
assert r.has_blocking_risk(change) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_blocking_risk_false_for_clean_entry():
|
||||||
|
change = r.EntryChange("x", "changed", _entry(), _entry(description="new"))
|
||||||
|
assert r.has_blocking_risk(change) is False
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# ReviewSession: acknowledge gating
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_start_review_computes_diff():
|
||||||
|
old = _catalog()
|
||||||
|
new = _catalog(_entry())
|
||||||
|
session = r.start_review("sha1", old, new)
|
||||||
|
assert len(session.changes) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_entries_acknowledged_false_initially():
|
||||||
|
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
|
||||||
|
assert r.all_entries_acknowledged(session) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_acknowledge_entry_marks_acknowledged():
|
||||||
|
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
|
||||||
|
r.acknowledge_entry(session, "filesystem")
|
||||||
|
assert r.all_entries_acknowledged(session) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_acknowledge_unknown_entry_raises():
|
||||||
|
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
r.acknowledge_entry(session, "not-in-diff")
|
||||||
|
|
||||||
|
|
||||||
|
def test_acknowledge_gating_requires_every_entry():
|
||||||
|
session = r.start_review("sha1", _catalog(), _catalog(_entry(id="a"), _entry(id="b")))
|
||||||
|
r.acknowledge_entry(session, "a")
|
||||||
|
assert r.all_entries_acknowledged(session) is False
|
||||||
|
r.acknowledge_entry(session, "b")
|
||||||
|
assert r.all_entries_acknowledged(session) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_acknowledge_all_shortcut_and_gate_is_real():
|
||||||
|
"""Two things, both load-bearing (issue #68: the original version of
|
||||||
|
this test asserted ONLY the first half, and passed the entire time the
|
||||||
|
gate below it was vacuously satisfiable -- 'no function named
|
||||||
|
acknowledge_all' is worthless if signing doesn't actually require
|
||||||
|
acknowledgement in practice).
|
||||||
|
|
||||||
|
1. No bulk-acknowledge shortcut exists (see the comment in
|
||||||
|
catalog_review.py above SignDecision -- deliberate friction).
|
||||||
|
2. The gate that friction protects is actually enforced: with entries
|
||||||
|
still unacknowledged, can_sign() must refuse, not just "some GUI
|
||||||
|
checkbox happens to be unticked".
|
||||||
|
"""
|
||||||
|
names = [n for n in dir(r) if "acknowledge" in n.lower()]
|
||||||
|
assert "acknowledge_all" not in names
|
||||||
|
assert "acknowledge_all_entries" not in names
|
||||||
|
|
||||||
|
session = r.start_review("sha1", _catalog(), _catalog(_entry(id="a"), _entry(id="b")))
|
||||||
|
r.acknowledge_entry(session, "a") # only one of two -- not a bulk call
|
||||||
|
decision = r.can_sign(session, "sha1")
|
||||||
|
assert decision.ok is False
|
||||||
|
assert "acknowledged" in decision.reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# can_sign: TOCTOU blob pinning + acknowledge gating combined
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_can_sign_false_when_not_all_acknowledged():
|
||||||
|
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
|
||||||
|
decision = r.can_sign(session, "sha1")
|
||||||
|
assert decision.ok is False
|
||||||
|
assert "acknowledged" in decision.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_sign_true_when_acknowledged_and_blob_matches():
|
||||||
|
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
|
||||||
|
r.acknowledge_entry(session, "filesystem")
|
||||||
|
decision = r.can_sign(session, "sha1")
|
||||||
|
assert decision.ok is True
|
||||||
|
assert decision.reason is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_sign_refuses_on_blob_mismatch_even_if_acknowledged():
|
||||||
|
"""The core TOCTOU fix: acknowledging everything is not enough if the
|
||||||
|
bytes on the remote changed underneath the review."""
|
||||||
|
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
|
||||||
|
r.acknowledge_entry(session, "filesystem")
|
||||||
|
decision = r.can_sign(session, "sha2-a-new-commit-landed")
|
||||||
|
assert decision.ok is False
|
||||||
|
assert "changed" in decision.reason.lower() or "mismatch" in decision.reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_sign_blob_mismatch_takes_priority_message():
|
||||||
|
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
|
||||||
|
decision = r.can_sign(session, "sha2")
|
||||||
|
assert decision.ok is False
|
||||||
|
assert "blob" in decision.reason.lower() or "changed" in decision.reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_sign_false_on_empty_changeset():
|
||||||
|
"""The exact bug behind issue #68 finding 1: 'main' loaded against
|
||||||
|
itself diffs to [], and an empty changeset used to leave can_sign()
|
||||||
|
with nothing to refuse on (set() <= set() is vacuously True). Commit
|
||||||
|
b08cf21 signed 19 entries through precisely this path -- zero of them
|
||||||
|
were ever reviewed. An empty diff must mean 'nothing to sign', never
|
||||||
|
'sign unlocked'."""
|
||||||
|
same_catalog = _catalog(_entry())
|
||||||
|
session = r.start_review("sha1", same_catalog, same_catalog)
|
||||||
|
assert session.changes == [] # diff_catalogs(x, x) -> []
|
||||||
|
assert r.all_entries_acknowledged(session) is True # vacuously -- this is the trap
|
||||||
|
decision = r.can_sign(session, "sha1") # blob matches, "everything" acknowledged
|
||||||
|
assert decision.ok is False
|
||||||
|
assert "nothing to sign" in decision.reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_sign_false_with_outstanding_blocking_risk_even_if_acknowledged():
|
||||||
|
"""can_sign() must itself refuse a blocking risk finding -- today a
|
||||||
|
blocking finding only disables the GUI checkbox, so the pure gate must
|
||||||
|
not simply trust that the caller never acknowledged a blocking entry.
|
||||||
|
Acknowledge it directly here (bypassing any GUI checkbox-disable logic
|
||||||
|
entirely) to prove the gate catches it independently of the GUI."""
|
||||||
|
session = r.start_review(
|
||||||
|
"sha1",
|
||||||
|
_catalog(),
|
||||||
|
_catalog(_entry(config={"command": "bash", "args": ["-c", "evil"]})),
|
||||||
|
)
|
||||||
|
r.acknowledge_entry(session, "filesystem")
|
||||||
|
assert r.all_entries_acknowledged(session) is True
|
||||||
|
decision = r.can_sign(session, "sha1")
|
||||||
|
assert decision.ok is False
|
||||||
|
assert "blocking" in decision.reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# sign_precondition: the ref-resolution seam that used to hardcode "main"
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_sign_precondition_resolves_against_loaded_ref_not_hardcoded_main():
|
||||||
|
"""The regression test for issue #68 finding 1's first bug:
|
||||||
|
ReviewWindow._on_sign used to hardcode fetch_ref(repo, "main") as the
|
||||||
|
TOCTOU comparison ref. For a PR review, _on_load pins the PR HEAD's
|
||||||
|
blob SHA, so comparing against main's SHA differs by definition and
|
||||||
|
Sign could never fire on the PR path.
|
||||||
|
|
||||||
|
The fake resolver below returns a DIFFERENT (deliberately wrong) SHA for
|
||||||
|
"main" than for the PR ref that was actually loaded. If
|
||||||
|
sign_precondition ever resolves against "main" instead of
|
||||||
|
session.loaded_ref, this test fails -- both via the recorded `calls`
|
||||||
|
list and via decision.ok flipping to False.
|
||||||
|
"""
|
||||||
|
pr_ref = "refs/pull/42/head"
|
||||||
|
session = r.start_review("pr-blob-sha", _catalog(), _catalog(_entry()), loaded_ref=pr_ref)
|
||||||
|
r.acknowledge_entry(session, "filesystem")
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def fake_resolver(ref: str) -> str:
|
||||||
|
calls.append(ref)
|
||||||
|
return {"main": "main-blob-sha-WRONG", pr_ref: "pr-blob-sha"}[ref]
|
||||||
|
|
||||||
|
decision = r.sign_precondition(session, fake_resolver)
|
||||||
|
assert calls == [pr_ref] # never asked the resolver for "main"
|
||||||
|
assert decision.ok is True
|
||||||
|
assert decision.reason is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_precondition_refuses_when_loaded_ref_blob_moved():
|
||||||
|
"""Same seam, the negative case: if the loaded ref's blob SHA has moved
|
||||||
|
since review began (a new commit landed on the reviewed PR/branch), the
|
||||||
|
resolver reflects that and sign_precondition must refuse -- proving this
|
||||||
|
isn't just a hardcoded pass-through."""
|
||||||
|
pr_ref = "refs/pull/42/head"
|
||||||
|
session = r.start_review("pr-blob-sha", _catalog(), _catalog(_entry()), loaded_ref=pr_ref)
|
||||||
|
r.acknowledge_entry(session, "filesystem")
|
||||||
|
|
||||||
|
def fake_resolver(_ref: str) -> str:
|
||||||
|
return "pr-blob-sha-AFTER-A-NEW-PUSH"
|
||||||
|
|
||||||
|
decision = r.sign_precondition(session, fake_resolver)
|
||||||
|
assert decision.ok is False
|
||||||
|
assert "mismatch" in decision.reason.lower() or "changed" in decision.reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_precondition_defaults_to_main_when_loaded_ref_unset():
|
||||||
|
"""start_review()'s loaded_ref defaults to 'main' for source=main
|
||||||
|
reviews (and backward-compat with callers that don't pass it)."""
|
||||||
|
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
|
||||||
|
assert session.loaded_ref == "main"
|
||||||
|
r.acknowledge_entry(session, "filesystem")
|
||||||
|
|
||||||
|
def fake_resolver(ref: str) -> str:
|
||||||
|
assert ref == "main"
|
||||||
|
return "sha1"
|
||||||
|
|
||||||
|
decision = r.sign_precondition(session, fake_resolver)
|
||||||
|
assert decision.ok is True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# find_last_signed_catalog_raw: what source=main diffs against
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_find_last_signed_catalog_raw_returns_matching_candidate():
|
||||||
|
"""Simulates walking catalog.json's git history: the CURRENT signature
|
||||||
|
covers an OLDER version of the bytes (a later commit changed
|
||||||
|
catalog.json without re-signing -- the exact bypass that produced
|
||||||
|
commit b08cf21). The first candidate that verifies against that
|
||||||
|
signature is 'the last catalog a maintainer actually signed'."""
|
||||||
|
seed, pubkey = r.generate_keypair()
|
||||||
|
old_raw = b'{"schema":1,"version":1,"servers":[]}'
|
||||||
|
new_raw = b'{"schema":1,"version":2,"servers":[]}'
|
||||||
|
sig = r.sign_catalog_bytes(old_raw, seed) # signature covers the OLD bytes
|
||||||
|
found = r.find_last_signed_catalog_raw([new_raw, old_raw], sig, [pubkey])
|
||||||
|
assert found == old_raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_last_signed_catalog_raw_none_when_nothing_verifies():
|
||||||
|
seed, _pubkey = r.generate_keypair()
|
||||||
|
_other_seed, other_pubkey = r.generate_keypair()
|
||||||
|
raw = b'{"schema":1,"version":1,"servers":[]}'
|
||||||
|
sig = r.sign_catalog_bytes(raw, seed)
|
||||||
|
# Check against a pubkey list that does NOT include the signer's key.
|
||||||
|
assert r.find_last_signed_catalog_raw([raw], sig, [other_pubkey]) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# catalog_signing_message: domain separation must match bcc_core exactly
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_signing_message_uses_bcc_core_domain_prefix():
|
||||||
|
raw = b'{"schema":1}'
|
||||||
|
msg = r.catalog_signing_message(raw)
|
||||||
|
assert msg == c._CATALOG_SIG_DOMAIN + raw
|
||||||
|
assert msg.startswith(b"bcc-catalog-v1|")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_then_verify_round_trips_with_bcc_core():
|
||||||
|
"""End-to-end: a signature produced by the Console's sign_catalog_bytes
|
||||||
|
must verify with bcc_core.verify_catalog_signature -- proves the two
|
||||||
|
modules can never drift on the domain-separation prefix."""
|
||||||
|
seed, pubkey = r.generate_keypair()
|
||||||
|
raw = b'{"schema":1,"version":2,"servers":[]}'
|
||||||
|
sig = r.sign_catalog_bytes(raw, seed)
|
||||||
|
assert c.verify_catalog_signature(raw, sig, [pubkey]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_tampered_bytes_fails_verify():
|
||||||
|
seed, pubkey = r.generate_keypair()
|
||||||
|
raw = b'{"schema":1,"version":2,"servers":[]}'
|
||||||
|
sig = r.sign_catalog_bytes(raw, seed)
|
||||||
|
tampered = raw[:-1] + b"0"
|
||||||
|
assert c.verify_catalog_signature(tampered, sig, [pubkey]) is False
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Key encryption at rest
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_encrypt_decrypt_round_trip():
|
||||||
|
seed, _pub = r.generate_keypair()
|
||||||
|
blob = r.encrypt_private_key(seed, "correct horse battery staple")
|
||||||
|
decrypted = r.decrypt_private_key(blob, "correct horse battery staple")
|
||||||
|
assert decrypted == seed
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrypt_wrong_passphrase_raises():
|
||||||
|
seed, _pub = r.generate_keypair()
|
||||||
|
blob = r.encrypt_private_key(seed, "right passphrase")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
r.decrypt_private_key(blob, "wrong passphrase")
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrypt_corrupted_blob_raises():
|
||||||
|
seed, _pub = r.generate_keypair()
|
||||||
|
blob = r.encrypt_private_key(seed, "pass")
|
||||||
|
corrupted = blob[:-1] + bytes([blob[-1] ^ 0xFF])
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
r.decrypt_private_key(corrupted, "pass")
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypt_private_key_rejects_wrong_length_seed():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
r.encrypt_private_key(b"too-short", "pass")
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypt_private_key_rejects_empty_passphrase():
|
||||||
|
seed, _pub = r.generate_keypair()
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
r.encrypt_private_key(seed, "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypted_blob_never_contains_seed_plaintext():
|
||||||
|
seed, _pub = r.generate_keypair()
|
||||||
|
blob = r.encrypt_private_key(seed, "some passphrase")
|
||||||
|
assert seed not in blob
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_keypair_produces_valid_ed25519_pair():
|
||||||
|
seed, pubkey = r.generate_keypair()
|
||||||
|
assert len(seed) == 32
|
||||||
|
assert len(pubkey) == 32
|
||||||
|
raw = b"test payload"
|
||||||
|
sig = r.sign_catalog_bytes(raw, seed)
|
||||||
|
assert c.verify_catalog_signature(raw, sig, [pubkey]) is True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Registry lookup / near-neighbour edit distance
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_edit_distance_identical():
|
||||||
|
assert r.edit_distance("abc", "abc") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_distance_one_substitution():
|
||||||
|
assert r.edit_distance("firecrawl-mcp", "f1recrawl-mcp") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_near_neighbor_ids_finds_close_match():
|
||||||
|
others = ["firecrawl-mcp", "unrelated-server", "totally-different"]
|
||||||
|
neighbors = r.near_neighbor_ids("firecrawl-mcp2", others, max_distance=2)
|
||||||
|
assert "firecrawl-mcp" in neighbors
|
||||||
|
|
||||||
|
|
||||||
|
def test_near_neighbor_ids_excludes_self():
|
||||||
|
others = ["filesystem", "other"]
|
||||||
|
assert "filesystem" not in r.near_neighbor_ids("filesystem", others)
|
||||||
|
|
||||||
|
|
||||||
|
def test_near_neighbor_ids_excludes_far_matches():
|
||||||
|
others = ["completely-unrelated-name"]
|
||||||
|
assert r.near_neighbor_ids("filesystem", others, max_distance=2) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_package_refs_npm():
|
||||||
|
entry = _entry(config={"command": "npx", "args": ["-y", "@scope/pkg@1.2.3"]})
|
||||||
|
refs = r.extract_package_refs(entry)
|
||||||
|
assert len(refs) == 1
|
||||||
|
assert refs[0].ecosystem == "npm"
|
||||||
|
assert refs[0].name == "@scope/pkg"
|
||||||
|
assert refs[0].version == "1.2.3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_package_refs_uvx():
|
||||||
|
entry = _entry(config={"command": "uvx", "args": ["some-pypi-pkg==1.0.0"]})
|
||||||
|
refs = r.extract_package_refs(entry)
|
||||||
|
assert len(refs) == 1
|
||||||
|
assert refs[0].ecosystem == "pypi"
|
||||||
|
assert refs[0].name == "some-pypi-pkg"
|
||||||
|
assert refs[0].version == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_package_refs_link_only_entry_returns_empty():
|
||||||
|
entry = {"id": "slack", "setup": "link-only", "docs_url": "https://example.com"}
|
||||||
|
assert r.extract_package_refs(entry) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_registry_info_fails_soft_on_none():
|
||||||
|
ref = r.PackageRef("x", "npm", "somepkg", "1.0.0")
|
||||||
|
info = r.lookup_registry_info(ref, lambda _ref: None, [])
|
||||||
|
assert info.available is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_registry_info_fails_soft_on_exception():
|
||||||
|
def boom(_ref):
|
||||||
|
raise RuntimeError("network down")
|
||||||
|
|
||||||
|
ref = r.PackageRef("x", "npm", "somepkg", "1.0.0")
|
||||||
|
info = r.lookup_registry_info(ref, boom, [])
|
||||||
|
assert info.available is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_registry_info_populates_fields_when_available():
|
||||||
|
ref = r.PackageRef("x", "npm", "somepkg", "1.0.0")
|
||||||
|
|
||||||
|
def fetcher(_ref):
|
||||||
|
return {
|
||||||
|
"publisher": "hello_sideguide",
|
||||||
|
"age_days": 30,
|
||||||
|
"last_release": "2026-01-01",
|
||||||
|
"downloads": 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
info = r.lookup_registry_info(ref, fetcher, [])
|
||||||
|
assert info.available is True
|
||||||
|
assert info.publisher == "hello_sideguide"
|
||||||
|
assert info.downloads == 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_registry_info_includes_near_neighbors():
|
||||||
|
ref = r.PackageRef("x", "npm", "firecrawl-mcp2", "1.0.0")
|
||||||
|
info = r.lookup_registry_info(ref, lambda _ref: None, ["firecrawl-mcp"])
|
||||||
|
assert "firecrawl-mcp" in info.near_neighbor_ids
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# contains_non_ascii
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_contains_non_ascii_true():
|
||||||
|
assert r.contains_non_ascii("pаckage") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_contains_non_ascii_false():
|
||||||
|
assert r.contains_non_ascii("package") is False
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Tests for scripts/sign_checksums.py: SHA256SUMS generation and detached
|
||||||
|
Ed25519 signing/verification for release artifacts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||||
|
|
||||||
|
import sign_checksums as sc
|
||||||
|
|
||||||
|
cryptography = pytest.importorskip("cryptography")
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _make_keypair() -> tuple[str, str]:
|
||||||
|
"""Return (seed_b64, pubkey_b64) for a fresh Ed25519 keypair."""
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
seed = private_key.private_bytes_raw()
|
||||||
|
pubkey = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||||
|
return base64.b64encode(seed).decode("ascii"), base64.b64encode(pubkey).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# sha256_file / build_checksums_text / generate_checksums
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_sha256_file_matches_hashlib(tmp_path):
|
||||||
|
f = tmp_path / "a.txt"
|
||||||
|
f.write_bytes(b"hello world")
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
assert sc.sha256_file(f) == hashlib.sha256(b"hello world").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_checksums_text_sorted_and_formatted():
|
||||||
|
files = {"zeta.zip": "aa" * 32, "alpha.zip": "bb" * 32}
|
||||||
|
text = sc.build_checksums_text(files)
|
||||||
|
lines = text.splitlines()
|
||||||
|
assert lines[0].endswith("alpha.zip")
|
||||||
|
assert lines[1].endswith("zeta.zip")
|
||||||
|
# Standard sha256sum format: hash, two spaces, filename.
|
||||||
|
assert lines[0] == f"{'bb' * 32} alpha.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_checksums_text_empty():
|
||||||
|
assert sc.build_checksums_text({}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_checksums_from_directory(tmp_path):
|
||||||
|
(tmp_path / "b.bin").write_bytes(b"second")
|
||||||
|
(tmp_path / "a.bin").write_bytes(b"first")
|
||||||
|
(tmp_path / "subdir").mkdir()
|
||||||
|
(tmp_path / "subdir" / "ignored.bin").write_bytes(b"nested, not hashed")
|
||||||
|
|
||||||
|
text = sc.generate_checksums(tmp_path)
|
||||||
|
lines = text.splitlines()
|
||||||
|
assert len(lines) == 2
|
||||||
|
assert lines[0].endswith("a.bin")
|
||||||
|
assert lines[1].endswith("b.bin")
|
||||||
|
assert "subdir" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_checksums_excludes_manifest_files(tmp_path):
|
||||||
|
(tmp_path / "archive.zip").write_bytes(b"payload")
|
||||||
|
(tmp_path / "SHA256SUMS").write_text("stale")
|
||||||
|
(tmp_path / "SHA256SUMS.sig").write_bytes(b"stale-sig")
|
||||||
|
|
||||||
|
text = sc.generate_checksums(tmp_path, exclude={"SHA256SUMS", "SHA256SUMS.sig"})
|
||||||
|
assert "archive.zip" in text
|
||||||
|
assert "SHA256SUMS" not in text.replace("archive.zip", "")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# sign_checksums / verify_checksums
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_sign_then_verify_roundtrip():
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "deadbeef" * 8 + " BetterClaudeConfig-Linux.tar.gz\n"
|
||||||
|
|
||||||
|
signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
assert len(signature) == 64
|
||||||
|
assert sc.verify_checksums(pubkey_b64, sums_text, signature) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_tampered_checksums():
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "aa" * 32 + " file.zip\n"
|
||||||
|
signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
|
||||||
|
tampered = "bb" * 32 + " file.zip\n"
|
||||||
|
assert sc.verify_checksums(pubkey_b64, tampered, signature) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_wrong_key():
|
||||||
|
seed_b64, _ = _make_keypair()
|
||||||
|
_, other_pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "cc" * 32 + " file.zip\n"
|
||||||
|
signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
|
||||||
|
assert sc.verify_checksums(other_pubkey_b64, sums_text, signature) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_domain_prefix_is_applied():
|
||||||
|
"""The signed message must be prefixed, not the raw manifest bytes --
|
||||||
|
otherwise a signature over this manifest could be replayed as a
|
||||||
|
signature over an unrelated message with the same bytes elsewhere."""
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "11" * 32 + " file.zip\n"
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey as PK
|
||||||
|
|
||||||
|
seed = base64.b64decode(seed_b64)
|
||||||
|
raw_signature = PK.from_private_bytes(seed).sign(sums_text.encode("utf-8"))
|
||||||
|
|
||||||
|
# A signature over the raw (unprefixed) bytes must NOT verify via our
|
||||||
|
# domain-separated verify function.
|
||||||
|
assert sc.verify_checksums(pubkey_b64, sums_text, raw_signature) is False
|
||||||
|
|
||||||
|
# But our own sign_checksums() output does verify.
|
||||||
|
good_signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
assert sc.verify_checksums(pubkey_b64, sums_text, good_signature) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_checksums_rejects_bad_seed_length():
|
||||||
|
bad_seed_b64 = base64.b64encode(b"too-short").decode("ascii")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sc.sign_checksums(bad_seed_b64, "irrelevant\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_checksums_rejects_bad_pubkey_length():
|
||||||
|
seed_b64, _ = _make_keypair()
|
||||||
|
sig = sc.sign_checksums(seed_b64, "irrelevant\n")
|
||||||
|
bad_pubkey_b64 = base64.b64encode(b"too-short").decode("ascii")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sc.verify_checksums(bad_pubkey_b64, "irrelevant\n", sig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_key_b64_from_seed_matches_generated_pubkey():
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
assert sc.public_key_b64_from_seed(seed_b64) == pubkey_b64
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI (end-to-end, via subprocess so argparse wiring is exercised too)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "sign_checksums.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _run(*args, env=None):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_generate_sign_verify_roundtrip(tmp_path, monkeypatch):
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
|
||||||
|
release_dir = tmp_path / "release-files"
|
||||||
|
release_dir.mkdir()
|
||||||
|
(release_dir / "BetterClaudeConfig-Linux.tar.gz").write_bytes(b"fake archive contents")
|
||||||
|
(release_dir / "BetterClaudeConfig-macOS.zip").write_bytes(b"fake zip contents")
|
||||||
|
|
||||||
|
sums_path = release_dir / "SHA256SUMS"
|
||||||
|
sig_path = release_dir / "SHA256SUMS.sig"
|
||||||
|
|
||||||
|
gen = _run("generate", str(release_dir), "--out", str(sums_path))
|
||||||
|
assert gen.returncode == 0, gen.stderr
|
||||||
|
assert sums_path.exists()
|
||||||
|
body = sums_path.read_text()
|
||||||
|
assert "BetterClaudeConfig-Linux.tar.gz" in body
|
||||||
|
assert "BetterClaudeConfig-macOS.zip" in body
|
||||||
|
|
||||||
|
sign = _run("sign", "--sums", str(sums_path), "--out", str(sig_path), "--key-b64", seed_b64)
|
||||||
|
assert sign.returncode == 0, sign.stderr
|
||||||
|
assert sig_path.exists()
|
||||||
|
assert sig_path.stat().st_size == 64
|
||||||
|
|
||||||
|
verify = _run(
|
||||||
|
"verify",
|
||||||
|
"--sums",
|
||||||
|
str(sums_path),
|
||||||
|
"--sig",
|
||||||
|
str(sig_path),
|
||||||
|
"--pubkey-b64",
|
||||||
|
pubkey_b64,
|
||||||
|
)
|
||||||
|
assert verify.returncode == 0, verify.stderr
|
||||||
|
assert "OK" in verify.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_sign_without_key_fails_loudly(tmp_path):
|
||||||
|
sums_path = tmp_path / "SHA256SUMS"
|
||||||
|
sums_path.write_text("aa" * 32 + " file.zip\n")
|
||||||
|
sig_path = tmp_path / "SHA256SUMS.sig"
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
env = {k: v for k, v in os.environ.items() if k != "RELEASE_SIGNING_KEY"}
|
||||||
|
result = _run("sign", "--sums", str(sums_path), "--out", str(sig_path), env=env)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert not sig_path.exists(), "must never write a bogus/empty signature file"
|
||||||
|
assert "no signing key" in result.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_verify_detects_tampering(tmp_path):
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_path = tmp_path / "SHA256SUMS"
|
||||||
|
sums_path.write_text("aa" * 32 + " file.zip\n")
|
||||||
|
sig_path = tmp_path / "SHA256SUMS.sig"
|
||||||
|
|
||||||
|
_run("sign", "--sums", str(sums_path), "--out", str(sig_path), "--key-b64", seed_b64)
|
||||||
|
|
||||||
|
sums_path.write_text("bb" * 32 + " file.zip\n") # tamper after signing
|
||||||
|
verify = _run(
|
||||||
|
"verify",
|
||||||
|
"--sums",
|
||||||
|
str(sums_path),
|
||||||
|
"--sig",
|
||||||
|
str(sig_path),
|
||||||
|
"--pubkey-b64",
|
||||||
|
pubkey_b64,
|
||||||
|
)
|
||||||
|
assert verify.returncode != 0
|
||||||
|
assert "FAILED" in verify.stdout + verify.stderr
|
||||||
+3450
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user